Merge branch 'feature/project-list-grouping' into upstream/feature/project-groupby
This commit is contained in:
@@ -47,12 +47,17 @@ FRONTEND_URL=http://localhost:5000
|
||||
# STORAGE
|
||||
STORAGE_PROVIDER=s3 # values s3 or azure
|
||||
|
||||
# AWS
|
||||
# AWS - SES
|
||||
AWS_REGION="your_aws_region"
|
||||
AWS_ACCESS_KEY_ID="your_aws_access_key_id"
|
||||
AWS_SECRET_ACCESS_KEY="your_aws_secret_access_key"
|
||||
AWS_BUCKET="your_s3_bucket"
|
||||
|
||||
# S3
|
||||
S3_REGION="S3_REGION"
|
||||
S3_BUCKET="your_s3_bucket"
|
||||
S3_URL="your_s3_url"
|
||||
S3_ACCESS_KEY_ID="S3_ACCESS_KEY_ID"
|
||||
S3_SECRET_ACCESS_KEY="S3_SECRET_ACCESS_KEY"
|
||||
|
||||
# Azure Storage
|
||||
AZURE_STORAGE_ACCOUNT_NAME="your_storage_account_name"
|
||||
@@ -73,4 +78,8 @@ GOOGLE_CAPTCHA_SECRET_KEY=your_captcha_secret_key
|
||||
GOOGLE_CAPTCHA_PASS_SCORE=0.8
|
||||
|
||||
# Email Cronjobs
|
||||
ENABLE_EMAIL_CRONJOBS=true
|
||||
ENABLE_EMAIL_CRONJOBS=true
|
||||
|
||||
# RECURRING_JOBS
|
||||
ENABLE_RECURRING_JOBS=true
|
||||
RECURRING_JOBS_INTERVAL="0 11 */1 * 1-5"
|
||||
@@ -1,131 +0,0 @@
|
||||
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", []);
|
||||
};
|
||||
@@ -0,0 +1,78 @@
|
||||
-- Migration: Add manual task progress
|
||||
-- Date: 2025-04-22
|
||||
-- Version: 1.0.0
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- Add manual progress fields to tasks table
|
||||
ALTER TABLE tasks
|
||||
ADD COLUMN IF NOT EXISTS manual_progress BOOLEAN DEFAULT FALSE,
|
||||
ADD COLUMN IF NOT EXISTS progress_value INTEGER DEFAULT NULL,
|
||||
ADD COLUMN IF NOT EXISTS weight INTEGER DEFAULT NULL;
|
||||
|
||||
-- Update function to consider manual progress
|
||||
CREATE OR REPLACE FUNCTION get_task_complete_ratio(_task_id uuid) RETURNS json
|
||||
LANGUAGE plpgsql
|
||||
AS
|
||||
$$
|
||||
DECLARE
|
||||
_parent_task_done FLOAT = 0;
|
||||
_sub_tasks_done FLOAT = 0;
|
||||
_sub_tasks_count FLOAT = 0;
|
||||
_total_completed FLOAT = 0;
|
||||
_total_tasks FLOAT = 0;
|
||||
_ratio FLOAT = 0;
|
||||
_is_manual BOOLEAN = FALSE;
|
||||
_manual_value INTEGER = NULL;
|
||||
BEGIN
|
||||
-- Check if manual progress is set
|
||||
SELECT manual_progress, progress_value
|
||||
FROM tasks
|
||||
WHERE id = _task_id
|
||||
INTO _is_manual, _manual_value;
|
||||
|
||||
-- If manual progress is enabled and has a value, use it directly
|
||||
IF _is_manual IS TRUE AND _manual_value IS NOT NULL THEN
|
||||
RETURN JSON_BUILD_OBJECT(
|
||||
'ratio', _manual_value,
|
||||
'total_completed', 0,
|
||||
'total_tasks', 0,
|
||||
'is_manual', TRUE
|
||||
);
|
||||
END IF;
|
||||
|
||||
-- Otherwise calculate automatically as before
|
||||
SELECT (CASE
|
||||
WHEN EXISTS(SELECT 1
|
||||
FROM tasks_with_status_view
|
||||
WHERE tasks_with_status_view.task_id = _task_id
|
||||
AND is_done IS TRUE) THEN 1
|
||||
ELSE 0 END)
|
||||
INTO _parent_task_done;
|
||||
SELECT COUNT(*) FROM tasks WHERE parent_task_id = _task_id AND archived IS FALSE INTO _sub_tasks_count;
|
||||
|
||||
SELECT COUNT(*)
|
||||
FROM tasks_with_status_view
|
||||
WHERE parent_task_id = _task_id
|
||||
AND is_done IS TRUE
|
||||
INTO _sub_tasks_done;
|
||||
|
||||
_total_completed = _parent_task_done + _sub_tasks_done;
|
||||
_total_tasks = _sub_tasks_count; -- +1 for the parent task
|
||||
|
||||
IF _total_tasks > 0 THEN
|
||||
_ratio = (_total_completed / _total_tasks) * 100;
|
||||
ELSE
|
||||
_ratio = _parent_task_done * 100;
|
||||
END IF;
|
||||
|
||||
RETURN JSON_BUILD_OBJECT(
|
||||
'ratio', _ratio,
|
||||
'total_completed', _total_completed,
|
||||
'total_tasks', _total_tasks,
|
||||
'is_manual', FALSE
|
||||
);
|
||||
END
|
||||
$$;
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,687 @@
|
||||
-- Migration: Enhance manual task progress with subtask support
|
||||
-- Date: 2025-04-23
|
||||
-- Version: 1.0.0
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- Update function to consider subtask manual progress when calculating parent task progress
|
||||
CREATE OR REPLACE FUNCTION get_task_complete_ratio(_task_id uuid) RETURNS json
|
||||
LANGUAGE plpgsql
|
||||
AS
|
||||
$$
|
||||
DECLARE
|
||||
_parent_task_done FLOAT = 0;
|
||||
_sub_tasks_done FLOAT = 0;
|
||||
_sub_tasks_count FLOAT = 0;
|
||||
_total_completed FLOAT = 0;
|
||||
_total_tasks FLOAT = 0;
|
||||
_ratio FLOAT = 0;
|
||||
_is_manual BOOLEAN = FALSE;
|
||||
_manual_value INTEGER = NULL;
|
||||
_project_id UUID;
|
||||
_use_manual_progress BOOLEAN = FALSE;
|
||||
_use_weighted_progress BOOLEAN = FALSE;
|
||||
_use_time_progress BOOLEAN = FALSE;
|
||||
BEGIN
|
||||
-- Check if manual progress is set for this task
|
||||
SELECT manual_progress, progress_value, project_id
|
||||
FROM tasks
|
||||
WHERE id = _task_id
|
||||
INTO _is_manual, _manual_value, _project_id;
|
||||
|
||||
-- Check if the project uses manual progress
|
||||
IF _project_id IS NOT NULL THEN
|
||||
SELECT COALESCE(use_manual_progress, FALSE),
|
||||
COALESCE(use_weighted_progress, FALSE),
|
||||
COALESCE(use_time_progress, FALSE)
|
||||
FROM projects
|
||||
WHERE id = _project_id
|
||||
INTO _use_manual_progress, _use_weighted_progress, _use_time_progress;
|
||||
END IF;
|
||||
|
||||
-- Get all subtasks
|
||||
SELECT COUNT(*)
|
||||
FROM tasks
|
||||
WHERE parent_task_id = _task_id AND archived IS FALSE
|
||||
INTO _sub_tasks_count;
|
||||
|
||||
-- If manual progress is enabled and has a value AND there are no subtasks, use it directly
|
||||
IF _is_manual IS TRUE AND _manual_value IS NOT NULL AND _sub_tasks_count = 0 THEN
|
||||
RETURN JSON_BUILD_OBJECT(
|
||||
'ratio', _manual_value,
|
||||
'total_completed', 0,
|
||||
'total_tasks', 0,
|
||||
'is_manual', TRUE
|
||||
);
|
||||
END IF;
|
||||
|
||||
-- If there are no subtasks, just use the parent task's status
|
||||
IF _sub_tasks_count = 0 THEN
|
||||
SELECT (CASE
|
||||
WHEN EXISTS(SELECT 1
|
||||
FROM tasks_with_status_view
|
||||
WHERE tasks_with_status_view.task_id = _task_id
|
||||
AND is_done IS TRUE) THEN 1
|
||||
ELSE 0 END)
|
||||
INTO _parent_task_done;
|
||||
|
||||
_ratio = _parent_task_done * 100;
|
||||
ELSE
|
||||
-- If project uses manual progress, calculate based on subtask manual progress values
|
||||
IF _use_manual_progress IS TRUE THEN
|
||||
WITH subtask_progress AS (
|
||||
SELECT
|
||||
CASE
|
||||
-- If subtask has manual progress, use that value
|
||||
WHEN manual_progress IS TRUE AND progress_value IS NOT NULL THEN
|
||||
progress_value
|
||||
-- Otherwise use completion status (0 or 100)
|
||||
ELSE
|
||||
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 100
|
||||
ELSE 0
|
||||
END
|
||||
END AS progress_value
|
||||
FROM tasks t
|
||||
WHERE t.parent_task_id = _task_id
|
||||
AND t.archived IS FALSE
|
||||
)
|
||||
SELECT COALESCE(AVG(progress_value), 0)
|
||||
FROM subtask_progress
|
||||
INTO _ratio;
|
||||
-- If project uses weighted progress, calculate based on subtask weights
|
||||
ELSIF _use_weighted_progress IS TRUE THEN
|
||||
WITH subtask_progress AS (
|
||||
SELECT
|
||||
CASE
|
||||
-- If subtask has manual progress, use that value
|
||||
WHEN manual_progress IS TRUE AND progress_value IS NOT NULL THEN
|
||||
progress_value
|
||||
-- Otherwise use completion status (0 or 100)
|
||||
ELSE
|
||||
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 100
|
||||
ELSE 0
|
||||
END
|
||||
END AS progress_value,
|
||||
COALESCE(weight, 100) AS weight
|
||||
FROM tasks t
|
||||
WHERE t.parent_task_id = _task_id
|
||||
AND t.archived IS FALSE
|
||||
)
|
||||
SELECT COALESCE(
|
||||
SUM(progress_value * weight) / NULLIF(SUM(weight), 0),
|
||||
0
|
||||
)
|
||||
FROM subtask_progress
|
||||
INTO _ratio;
|
||||
-- If project uses time-based progress, calculate based on estimated time
|
||||
ELSIF _use_time_progress IS TRUE THEN
|
||||
WITH subtask_progress AS (
|
||||
SELECT
|
||||
CASE
|
||||
-- If subtask has manual progress, use that value
|
||||
WHEN manual_progress IS TRUE AND progress_value IS NOT NULL THEN
|
||||
progress_value
|
||||
-- Otherwise use completion status (0 or 100)
|
||||
ELSE
|
||||
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 100
|
||||
ELSE 0
|
||||
END
|
||||
END AS progress_value,
|
||||
COALESCE(total_minutes, 0) AS estimated_minutes
|
||||
FROM tasks t
|
||||
WHERE t.parent_task_id = _task_id
|
||||
AND t.archived IS FALSE
|
||||
)
|
||||
SELECT COALESCE(
|
||||
SUM(progress_value * estimated_minutes) / NULLIF(SUM(estimated_minutes), 0),
|
||||
0
|
||||
)
|
||||
FROM subtask_progress
|
||||
INTO _ratio;
|
||||
ELSE
|
||||
-- Traditional calculation based on completion status
|
||||
SELECT (CASE
|
||||
WHEN EXISTS(SELECT 1
|
||||
FROM tasks_with_status_view
|
||||
WHERE tasks_with_status_view.task_id = _task_id
|
||||
AND is_done IS TRUE) THEN 1
|
||||
ELSE 0 END)
|
||||
INTO _parent_task_done;
|
||||
|
||||
SELECT COUNT(*)
|
||||
FROM tasks_with_status_view
|
||||
WHERE parent_task_id = _task_id
|
||||
AND is_done IS TRUE
|
||||
INTO _sub_tasks_done;
|
||||
|
||||
_total_completed = _parent_task_done + _sub_tasks_done;
|
||||
_total_tasks = _sub_tasks_count + 1; -- +1 for the parent task
|
||||
|
||||
IF _total_tasks = 0 THEN
|
||||
_ratio = 0;
|
||||
ELSE
|
||||
_ratio = (_total_completed / _total_tasks) * 100;
|
||||
END IF;
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
-- Ensure ratio is between 0 and 100
|
||||
IF _ratio < 0 THEN
|
||||
_ratio = 0;
|
||||
ELSIF _ratio > 100 THEN
|
||||
_ratio = 100;
|
||||
END IF;
|
||||
|
||||
RETURN JSON_BUILD_OBJECT(
|
||||
'ratio', _ratio,
|
||||
'total_completed', _total_completed,
|
||||
'total_tasks', _total_tasks,
|
||||
'is_manual', _is_manual
|
||||
);
|
||||
END
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION update_project(_body json) RETURNS json
|
||||
LANGUAGE plpgsql
|
||||
AS
|
||||
$$
|
||||
DECLARE
|
||||
_user_id UUID;
|
||||
_team_id UUID;
|
||||
_client_id UUID;
|
||||
_project_id UUID;
|
||||
_project_manager_team_member_id UUID;
|
||||
_client_name TEXT;
|
||||
_project_name TEXT;
|
||||
BEGIN
|
||||
-- need a test, can be throw errors
|
||||
_client_name = TRIM((_body ->> 'client_name')::TEXT);
|
||||
_project_name = TRIM((_body ->> 'name')::TEXT);
|
||||
|
||||
-- add inside the controller
|
||||
_user_id = (_body ->> 'user_id')::UUID;
|
||||
_team_id = (_body ->> 'team_id')::UUID;
|
||||
_project_manager_team_member_id = (_body ->> 'team_member_id')::UUID;
|
||||
|
||||
-- cache exists client if exists
|
||||
SELECT id FROM clients WHERE LOWER(name) = LOWER(_client_name) AND team_id = _team_id INTO _client_id;
|
||||
|
||||
-- insert client if not exists
|
||||
IF is_null_or_empty(_client_id) IS TRUE AND is_null_or_empty(_client_name) IS FALSE
|
||||
THEN
|
||||
INSERT INTO clients (name, team_id) VALUES (_client_name, _team_id) RETURNING id INTO _client_id;
|
||||
END IF;
|
||||
|
||||
-- check whether the project name is already in
|
||||
IF EXISTS(
|
||||
SELECT name FROM projects WHERE LOWER(name) = LOWER(_project_name)
|
||||
AND team_id = _team_id AND id != (_body ->> 'id')::UUID
|
||||
)
|
||||
THEN
|
||||
RAISE 'PROJECT_EXISTS_ERROR:%', _project_name;
|
||||
END IF;
|
||||
|
||||
-- update the project
|
||||
UPDATE projects
|
||||
SET name = _project_name,
|
||||
notes = (_body ->> 'notes')::TEXT,
|
||||
color_code = (_body ->> 'color_code')::TEXT,
|
||||
status_id = (_body ->> 'status_id')::UUID,
|
||||
health_id = (_body ->> 'health_id')::UUID,
|
||||
key = (_body ->> 'key')::TEXT,
|
||||
start_date = (_body ->> 'start_date')::TIMESTAMPTZ,
|
||||
end_date = (_body ->> 'end_date')::TIMESTAMPTZ,
|
||||
client_id = _client_id,
|
||||
folder_id = (_body ->> 'folder_id')::UUID,
|
||||
category_id = (_body ->> 'category_id')::UUID,
|
||||
updated_at = CURRENT_TIMESTAMP,
|
||||
estimated_working_days = (_body ->> 'working_days')::INTEGER,
|
||||
estimated_man_days = (_body ->> 'man_days')::INTEGER,
|
||||
hours_per_day = (_body ->> 'hours_per_day')::INTEGER,
|
||||
use_manual_progress = COALESCE((_body ->> 'use_manual_progress')::BOOLEAN, FALSE),
|
||||
use_weighted_progress = COALESCE((_body ->> 'use_weighted_progress')::BOOLEAN, FALSE),
|
||||
use_time_progress = COALESCE((_body ->> 'use_time_progress')::BOOLEAN, FALSE)
|
||||
WHERE id = (_body ->> 'id')::UUID
|
||||
AND team_id = _team_id
|
||||
RETURNING id INTO _project_id;
|
||||
|
||||
UPDATE project_members SET project_access_level_id = (SELECT id FROM project_access_levels WHERE key = 'MEMBER') WHERE project_id = _project_id;
|
||||
|
||||
IF NOT (_project_manager_team_member_id IS NULL)
|
||||
THEN
|
||||
PERFORM update_project_manager(_project_manager_team_member_id, _project_id::UUID);
|
||||
END IF;
|
||||
|
||||
RETURN JSON_BUILD_OBJECT(
|
||||
'id', _project_id,
|
||||
'name', (_body ->> 'name')::TEXT,
|
||||
'project_manager_id', _project_manager_team_member_id::UUID
|
||||
);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- 3. Also modify the create_project function to handle the new fields during project creation
|
||||
CREATE OR REPLACE FUNCTION create_project(_body json) RETURNS json
|
||||
LANGUAGE plpgsql
|
||||
AS
|
||||
$$
|
||||
DECLARE
|
||||
_project_id UUID;
|
||||
_user_id UUID;
|
||||
_team_id UUID;
|
||||
_team_member_id UUID;
|
||||
_client_id UUID;
|
||||
_client_name TEXT;
|
||||
_project_name TEXT;
|
||||
_project_created_log TEXT;
|
||||
_project_member_added_log TEXT;
|
||||
_project_created_log_id UUID;
|
||||
_project_manager_team_member_id UUID;
|
||||
_project_key TEXT;
|
||||
BEGIN
|
||||
_client_name = TRIM((_body ->> 'client_name')::TEXT);
|
||||
_project_name = TRIM((_body ->> 'name')::TEXT);
|
||||
_project_key = TRIM((_body ->> 'key')::TEXT);
|
||||
_project_created_log = (_body ->> 'project_created_log')::TEXT;
|
||||
_project_member_added_log = (_body ->> 'project_member_added_log')::TEXT;
|
||||
_user_id = (_body ->> 'user_id')::UUID;
|
||||
_team_id = (_body ->> 'team_id')::UUID;
|
||||
_project_manager_team_member_id = (_body ->> 'project_manager_id')::UUID;
|
||||
|
||||
SELECT id FROM team_members WHERE user_id = _user_id AND team_id = _team_id INTO _team_member_id;
|
||||
|
||||
-- cache exists client if exists
|
||||
SELECT id FROM clients WHERE LOWER(name) = LOWER(_client_name) AND team_id = _team_id INTO _client_id;
|
||||
|
||||
-- insert client if not exists
|
||||
IF is_null_or_empty(_client_id) IS TRUE AND is_null_or_empty(_client_name) IS FALSE
|
||||
THEN
|
||||
INSERT INTO clients (name, team_id) VALUES (_client_name, _team_id) RETURNING id INTO _client_id;
|
||||
END IF;
|
||||
|
||||
-- check whether the project name is already in
|
||||
IF EXISTS(SELECT name FROM projects WHERE LOWER(name) = LOWER(_project_name) AND team_id = _team_id)
|
||||
THEN
|
||||
RAISE 'PROJECT_EXISTS_ERROR:%', _project_name;
|
||||
END IF;
|
||||
|
||||
-- create the project
|
||||
INSERT
|
||||
INTO projects (name, key, color_code, start_date, end_date, team_id, notes, owner_id, status_id, health_id, folder_id,
|
||||
category_id, estimated_working_days, estimated_man_days, hours_per_day,
|
||||
use_manual_progress, use_weighted_progress, use_time_progress, client_id)
|
||||
VALUES (_project_name,
|
||||
UPPER(_project_key),
|
||||
(_body ->> 'color_code')::TEXT,
|
||||
(_body ->> 'start_date')::TIMESTAMPTZ,
|
||||
(_body ->> 'end_date')::TIMESTAMPTZ,
|
||||
_team_id,
|
||||
(_body ->> 'notes')::TEXT,
|
||||
_user_id,
|
||||
(_body ->> 'status_id')::UUID,
|
||||
(_body ->> 'health_id')::UUID,
|
||||
(_body ->> 'folder_id')::UUID,
|
||||
(_body ->> 'category_id')::UUID,
|
||||
(_body ->> 'working_days')::INTEGER,
|
||||
(_body ->> 'man_days')::INTEGER,
|
||||
(_body ->> 'hours_per_day')::INTEGER,
|
||||
COALESCE((_body ->> 'use_manual_progress')::BOOLEAN, FALSE),
|
||||
COALESCE((_body ->> 'use_weighted_progress')::BOOLEAN, FALSE),
|
||||
COALESCE((_body ->> 'use_time_progress')::BOOLEAN, FALSE),
|
||||
_client_id)
|
||||
RETURNING id INTO _project_id;
|
||||
|
||||
-- register the project log
|
||||
INSERT INTO project_logs (project_id, team_id, description)
|
||||
VALUES (_project_id, _team_id, _project_created_log)
|
||||
RETURNING id INTO _project_created_log_id;
|
||||
|
||||
-- insert the project creator as a project member
|
||||
INSERT INTO project_members (team_member_id, project_access_level_id, project_id, role_id)
|
||||
VALUES (_team_member_id, (SELECT id FROM project_access_levels WHERE key = 'ADMIN'),
|
||||
_project_id,
|
||||
(SELECT id FROM roles WHERE team_id = _team_id AND default_role IS TRUE));
|
||||
|
||||
-- insert statuses
|
||||
INSERT INTO task_statuses (name, project_id, team_id, category_id, sort_order)
|
||||
VALUES ('To Do', _project_id, _team_id, (SELECT id FROM sys_task_status_categories WHERE is_todo IS TRUE), 0);
|
||||
INSERT INTO task_statuses (name, project_id, team_id, category_id, sort_order)
|
||||
VALUES ('Doing', _project_id, _team_id, (SELECT id FROM sys_task_status_categories WHERE is_doing IS TRUE), 1);
|
||||
INSERT INTO task_statuses (name, project_id, team_id, category_id, sort_order)
|
||||
VALUES ('Done', _project_id, _team_id, (SELECT id FROM sys_task_status_categories WHERE is_done IS TRUE), 2);
|
||||
|
||||
-- insert default project columns
|
||||
PERFORM insert_task_list_columns(_project_id);
|
||||
|
||||
-- add project manager role if exists
|
||||
IF NOT is_null_or_empty(_project_manager_team_member_id) THEN
|
||||
PERFORM update_project_manager(_project_manager_team_member_id, _project_id);
|
||||
END IF;
|
||||
|
||||
RETURN JSON_BUILD_OBJECT(
|
||||
'id', _project_id,
|
||||
'name', _project_name,
|
||||
'project_created_log_id', _project_created_log_id
|
||||
);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- 4. Update the getById function to include the new fields in the response
|
||||
CREATE OR REPLACE FUNCTION getProjectById(_project_id UUID, _team_id UUID) RETURNS JSON
|
||||
LANGUAGE plpgsql
|
||||
AS
|
||||
$$
|
||||
DECLARE
|
||||
_result JSON;
|
||||
BEGIN
|
||||
SELECT ROW_TO_JSON(rec) INTO _result
|
||||
FROM (SELECT p.id,
|
||||
p.name,
|
||||
p.key,
|
||||
p.color_code,
|
||||
p.start_date,
|
||||
p.end_date,
|
||||
c.name AS client_name,
|
||||
c.id AS client_id,
|
||||
p.notes,
|
||||
p.created_at,
|
||||
p.updated_at,
|
||||
ts.name AS status,
|
||||
ts.color_code AS status_color,
|
||||
ts.icon AS status_icon,
|
||||
ts.id AS status_id,
|
||||
h.name AS health,
|
||||
h.color_code AS health_color,
|
||||
h.icon AS health_icon,
|
||||
h.id AS health_id,
|
||||
pc.name AS category_name,
|
||||
pc.color_code AS category_color,
|
||||
pc.id AS category_id,
|
||||
p.phase_label,
|
||||
p.estimated_man_days AS man_days,
|
||||
p.estimated_working_days AS working_days,
|
||||
p.hours_per_day,
|
||||
p.use_manual_progress,
|
||||
p.use_weighted_progress,
|
||||
-- Additional fields
|
||||
COALESCE((SELECT ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(t)))
|
||||
FROM (SELECT pm.id,
|
||||
pm.project_id,
|
||||
tm.id AS team_member_id,
|
||||
tm.user_id,
|
||||
u.name,
|
||||
u.email,
|
||||
u.avatar_url,
|
||||
u.phone_number,
|
||||
pal.name AS access_level,
|
||||
pal.key AS access_level_key,
|
||||
pal.id AS access_level_id,
|
||||
EXISTS(SELECT 1
|
||||
FROM project_members
|
||||
INNER JOIN project_access_levels ON
|
||||
project_members.project_access_level_id = project_access_levels.id
|
||||
WHERE project_id = p.id
|
||||
AND project_access_levels.key = 'PROJECT_MANAGER'
|
||||
AND team_member_id = tm.id) AS is_project_manager
|
||||
FROM project_members pm
|
||||
INNER JOIN team_members tm ON pm.team_member_id = tm.id
|
||||
INNER JOIN users u ON tm.user_id = u.id
|
||||
INNER JOIN project_access_levels pal ON pm.project_access_level_id = pal.id
|
||||
WHERE pm.project_id = p.id) t), '[]'::JSON) AS members,
|
||||
(SELECT COUNT(DISTINCT (id))
|
||||
FROM tasks
|
||||
WHERE archived IS FALSE
|
||||
AND project_id = p.id) AS task_count,
|
||||
(SELECT ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(t)))
|
||||
FROM (SELECT project_members.id,
|
||||
project_members.project_id,
|
||||
team_members.id AS team_member_id,
|
||||
team_members.user_id,
|
||||
users.name,
|
||||
users.email,
|
||||
users.avatar_url,
|
||||
project_access_levels.name AS access_level,
|
||||
project_access_levels.key AS access_level_key,
|
||||
project_access_levels.id AS access_level_id
|
||||
FROM project_members
|
||||
INNER JOIN team_members ON project_members.team_member_id = team_members.id
|
||||
INNER JOIN users ON team_members.user_id = users.id
|
||||
INNER JOIN project_access_levels
|
||||
ON project_members.project_access_level_id = project_access_levels.id
|
||||
WHERE project_id = p.id
|
||||
AND project_access_levels.key = 'PROJECT_MANAGER'
|
||||
LIMIT 1) t) AS project_manager,
|
||||
|
||||
(SELECT EXISTS(SELECT 1
|
||||
FROM project_subscribers
|
||||
WHERE project_id = p.id
|
||||
AND user_id = (SELECT user_id
|
||||
FROM project_members
|
||||
WHERE team_member_id = (SELECT id
|
||||
FROM team_members
|
||||
WHERE user_id IN
|
||||
(SELECT user_id FROM is_member_of_project_cte))
|
||||
AND project_id = p.id))) AS subscribed,
|
||||
(SELECT name
|
||||
FROM users
|
||||
WHERE id =
|
||||
(SELECT owner_id FROM projects WHERE id = p.id)) AS project_owner,
|
||||
(SELECT default_view
|
||||
FROM project_members
|
||||
WHERE project_id = p.id
|
||||
AND team_member_id IN (SELECT id FROM is_member_of_project_cte)) AS team_member_default_view,
|
||||
(SELECT EXISTS(SELECT user_id
|
||||
FROM archived_projects
|
||||
WHERE user_id IN (SELECT user_id FROM is_member_of_project_cte)
|
||||
AND project_id = p.id)) AS archived,
|
||||
|
||||
(SELECT EXISTS(SELECT user_id
|
||||
FROM favorite_projects
|
||||
WHERE user_id IN (SELECT user_id FROM is_member_of_project_cte)
|
||||
AND project_id = p.id)) AS favorite
|
||||
|
||||
FROM projects p
|
||||
LEFT JOIN sys_project_statuses ts ON p.status_id = ts.id
|
||||
LEFT JOIN sys_project_healths h ON p.health_id = h.id
|
||||
LEFT JOIN project_categories pc ON p.category_id = pc.id
|
||||
LEFT JOIN clients c ON p.client_id = c.id,
|
||||
LATERAL (SELECT id, user_id
|
||||
FROM team_members
|
||||
WHERE id = (SELECT team_member_id
|
||||
FROM project_members
|
||||
WHERE project_id = p.id
|
||||
AND team_member_id IN (SELECT id
|
||||
FROM team_members
|
||||
WHERE team_id = _team_id)
|
||||
LIMIT 1)) is_member_of_project_cte
|
||||
|
||||
WHERE p.id = _project_id
|
||||
AND p.team_id = _team_id) rec;
|
||||
|
||||
RETURN _result;
|
||||
END
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.get_task_form_view_model(_user_id UUID, _team_id UUID, _task_id UUID, _project_id UUID) RETURNS JSON
|
||||
LANGUAGE plpgsql
|
||||
AS
|
||||
$$
|
||||
DECLARE
|
||||
_task JSON;
|
||||
_priorities JSON;
|
||||
_projects JSON;
|
||||
_statuses JSON;
|
||||
_team_members JSON;
|
||||
_assignees JSON;
|
||||
_phases JSON;
|
||||
BEGIN
|
||||
|
||||
-- Select task info
|
||||
SELECT COALESCE(ROW_TO_JSON(rec), '{}'::JSON)
|
||||
INTO _task
|
||||
FROM (WITH RECURSIVE task_hierarchy AS (
|
||||
-- Base case: Start with the given task
|
||||
SELECT id,
|
||||
parent_task_id,
|
||||
0 AS level
|
||||
FROM tasks
|
||||
WHERE id = _task_id
|
||||
|
||||
UNION ALL
|
||||
|
||||
-- Recursive case: Traverse up to parent tasks
|
||||
SELECT t.id,
|
||||
t.parent_task_id,
|
||||
th.level + 1 AS level
|
||||
FROM tasks t
|
||||
INNER JOIN task_hierarchy th ON t.id = th.parent_task_id
|
||||
WHERE th.parent_task_id IS NOT NULL)
|
||||
SELECT id,
|
||||
name,
|
||||
description,
|
||||
start_date,
|
||||
end_date,
|
||||
done,
|
||||
total_minutes,
|
||||
priority_id,
|
||||
project_id,
|
||||
created_at,
|
||||
updated_at,
|
||||
status_id,
|
||||
parent_task_id,
|
||||
sort_order,
|
||||
(SELECT phase_id FROM task_phase WHERE task_id = tasks.id) AS phase_id,
|
||||
CONCAT((SELECT key FROM projects WHERE id = tasks.project_id), '-', task_no) AS task_key,
|
||||
(SELECT start_time
|
||||
FROM task_timers
|
||||
WHERE task_id = tasks.id
|
||||
AND user_id = _user_id) AS timer_start_time,
|
||||
parent_task_id IS NOT NULL AS is_sub_task,
|
||||
(SELECT COUNT('*')
|
||||
FROM tasks
|
||||
WHERE parent_task_id = tasks.id
|
||||
AND archived IS FALSE) AS sub_tasks_count,
|
||||
(SELECT COUNT(*)
|
||||
FROM tasks_with_status_view tt
|
||||
WHERE (tt.parent_task_id = tasks.id OR tt.task_id = tasks.id)
|
||||
AND tt.is_done IS TRUE)
|
||||
AS completed_count,
|
||||
(SELECT COUNT(*) FROM task_attachments WHERE task_id = tasks.id) AS attachments_count,
|
||||
(SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(r))), '[]'::JSON)
|
||||
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 = tasks.id
|
||||
ORDER BY name) r) AS labels,
|
||||
(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 COUNT(*) FROM tasks WHERE parent_task_id = _task_id) AS sub_tasks_count,
|
||||
(SELECT name FROM users WHERE id = tasks.reporter_id) AS reporter,
|
||||
(SELECT get_task_assignees(tasks.id)) AS assignees,
|
||||
(SELECT id FROM team_members WHERE user_id = _user_id AND team_id = _team_id) AS team_member_id,
|
||||
billable,
|
||||
schedule_id,
|
||||
progress_value,
|
||||
weight,
|
||||
(SELECT MAX(level) FROM task_hierarchy) AS task_level
|
||||
FROM tasks
|
||||
WHERE id = _task_id) rec;
|
||||
|
||||
SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(rec))), '[]'::JSON)
|
||||
INTO _priorities
|
||||
FROM (SELECT id, name FROM task_priorities ORDER BY value) rec;
|
||||
|
||||
SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(rec))), '[]'::JSON)
|
||||
INTO _phases
|
||||
FROM (SELECT id, name FROM project_phases WHERE project_id = _project_id ORDER BY name) rec;
|
||||
|
||||
SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(rec))), '[]'::JSON)
|
||||
INTO _projects
|
||||
FROM (SELECT id, name
|
||||
FROM projects
|
||||
WHERE team_id = _team_id
|
||||
AND (CASE
|
||||
WHEN (is_owner(_user_id, _team_id) OR is_admin(_user_id, _team_id) IS TRUE) THEN TRUE
|
||||
ELSE is_member_of_project(projects.id, _user_id, _team_id) END)
|
||||
ORDER BY name) rec;
|
||||
|
||||
SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(rec))), '[]'::JSON)
|
||||
INTO _statuses
|
||||
FROM (SELECT id, name FROM task_statuses WHERE project_id = _project_id) rec;
|
||||
|
||||
SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(rec))), '[]'::JSON)
|
||||
INTO _team_members
|
||||
FROM (SELECT team_members.id,
|
||||
(SELECT name FROM team_member_info_view WHERE team_member_info_view.team_member_id = team_members.id),
|
||||
(SELECT email FROM team_member_info_view WHERE team_member_info_view.team_member_id = team_members.id),
|
||||
(SELECT avatar_url
|
||||
FROM team_member_info_view
|
||||
WHERE team_member_info_view.team_member_id = team_members.id)
|
||||
FROM team_members
|
||||
LEFT JOIN users u ON team_members.user_id = u.id
|
||||
WHERE team_id = _team_id
|
||||
AND team_members.active IS TRUE) rec;
|
||||
|
||||
SELECT get_task_assignees(_task_id) INTO _assignees;
|
||||
|
||||
RETURN JSON_BUILD_OBJECT(
|
||||
'task', _task,
|
||||
'priorities', _priorities,
|
||||
'projects', _projects,
|
||||
'statuses', _statuses,
|
||||
'team_members', _team_members,
|
||||
'assignees', _assignees,
|
||||
'phases', _phases
|
||||
);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Add use_manual_progress, use_weighted_progress, and use_time_progress to projects table if they don't exist
|
||||
ALTER TABLE projects
|
||||
ADD COLUMN IF NOT EXISTS use_manual_progress BOOLEAN DEFAULT FALSE,
|
||||
ADD COLUMN IF NOT EXISTS use_weighted_progress BOOLEAN DEFAULT FALSE,
|
||||
ADD COLUMN IF NOT EXISTS use_time_progress BOOLEAN DEFAULT FALSE;
|
||||
|
||||
-- Add a trigger to reset manual progress when a task gets a new subtask
|
||||
CREATE OR REPLACE FUNCTION reset_parent_task_manual_progress() RETURNS TRIGGER AS
|
||||
$$
|
||||
BEGIN
|
||||
-- When a task gets a new subtask (parent_task_id is set), reset the parent's manual_progress flag
|
||||
IF NEW.parent_task_id IS NOT NULL THEN
|
||||
UPDATE tasks
|
||||
SET manual_progress = false
|
||||
WHERE id = NEW.parent_task_id
|
||||
AND manual_progress = true;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Create the trigger on the tasks table
|
||||
DROP TRIGGER IF EXISTS reset_parent_manual_progress_trigger ON tasks;
|
||||
CREATE TRIGGER reset_parent_manual_progress_trigger
|
||||
AFTER INSERT OR UPDATE OF parent_task_id ON tasks
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION reset_parent_task_manual_progress();
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,157 @@
|
||||
-- Migration: Add progress and weight activity types support
|
||||
-- Date: 2025-04-24
|
||||
-- Version: 1.0.0
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- Update the get_activity_logs_by_task function to handle progress and weight attribute types
|
||||
CREATE OR REPLACE FUNCTION get_activity_logs_by_task(_task_id uuid) RETURNS json
|
||||
LANGUAGE plpgsql
|
||||
AS
|
||||
$$
|
||||
DECLARE
|
||||
_result JSON;
|
||||
BEGIN
|
||||
SELECT ROW_TO_JSON(rec)
|
||||
INTO _result
|
||||
FROM (SELECT (SELECT tasks.created_at FROM tasks WHERE tasks.id = _task_id),
|
||||
(SELECT name
|
||||
FROM users
|
||||
WHERE id = (SELECT reporter_id FROM tasks WHERE id = _task_id)),
|
||||
(SELECT avatar_url
|
||||
FROM users
|
||||
WHERE id = (SELECT reporter_id FROM tasks WHERE id = _task_id)),
|
||||
(SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(rec2))), '[]'::JSON)
|
||||
FROM (SELECT task_id,
|
||||
created_at,
|
||||
attribute_type,
|
||||
log_type,
|
||||
|
||||
-- Case for previous value
|
||||
(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)
|
||||
WHEN (attribute_type = 'phase' AND old_value <> 'Unmapped')
|
||||
THEN (SELECT name FROM project_phases WHERE id = old_value::UUID)
|
||||
WHEN (attribute_type = 'progress' OR attribute_type = 'weight')
|
||||
THEN old_value
|
||||
ELSE (old_value) END) AS previous,
|
||||
|
||||
-- Case for current value
|
||||
(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)
|
||||
WHEN (attribute_type = 'phase' AND new_value <> 'Unmapped')
|
||||
THEN (SELECT name FROM project_phases WHERE id = new_value::UUID)
|
||||
WHEN (attribute_type = 'progress' OR attribute_type = 'weight')
|
||||
THEN new_value
|
||||
ELSE (new_value) END) AS current,
|
||||
|
||||
-- Case for assigned user
|
||||
(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,
|
||||
|
||||
-- Case for label data
|
||||
(CASE
|
||||
WHEN (attribute_type = 'label')
|
||||
THEN (SELECT ROW_TO_JSON(rec)
|
||||
FROM (SELECT (SELECT name FROM team_labels WHERE id = new_value::UUID),
|
||||
(SELECT color_code FROM team_labels WHERE id = new_value::UUID)) rec)
|
||||
ELSE (NULL) END) AS label_data,
|
||||
|
||||
-- Case for previous status
|
||||
(CASE
|
||||
WHEN (attribute_type = 'status')
|
||||
THEN (SELECT ROW_TO_JSON(rec)
|
||||
FROM (SELECT (SELECT name FROM task_statuses WHERE id = old_value::UUID),
|
||||
(SELECT color_code
|
||||
FROM sys_task_status_categories
|
||||
WHERE id = (SELECT category_id FROM task_statuses WHERE id = old_value::UUID)),
|
||||
(SELECT color_code_dark
|
||||
FROM sys_task_status_categories
|
||||
WHERE id = (SELECT category_id FROM task_statuses WHERE id = old_value::UUID))) rec)
|
||||
ELSE (NULL) END) AS previous_status,
|
||||
|
||||
-- Case for next status
|
||||
(CASE
|
||||
WHEN (attribute_type = 'status')
|
||||
THEN (SELECT ROW_TO_JSON(rec)
|
||||
FROM (SELECT (SELECT name FROM task_statuses WHERE id = new_value::UUID),
|
||||
(SELECT color_code
|
||||
FROM sys_task_status_categories
|
||||
WHERE id = (SELECT category_id FROM task_statuses WHERE id = new_value::UUID)),
|
||||
(SELECT color_code_dark
|
||||
FROM sys_task_status_categories
|
||||
WHERE id = (SELECT category_id FROM task_statuses WHERE id = new_value::UUID))) rec)
|
||||
ELSE (NULL) END) AS next_status,
|
||||
|
||||
-- Case for previous priority
|
||||
(CASE
|
||||
WHEN (attribute_type = 'priority')
|
||||
THEN (SELECT ROW_TO_JSON(rec)
|
||||
FROM (SELECT (SELECT name FROM task_priorities WHERE id = old_value::UUID),
|
||||
(SELECT color_code FROM task_priorities WHERE id = old_value::UUID)) rec)
|
||||
ELSE (NULL) END) AS previous_priority,
|
||||
|
||||
-- Case for next priority
|
||||
(CASE
|
||||
WHEN (attribute_type = 'priority')
|
||||
THEN (SELECT ROW_TO_JSON(rec)
|
||||
FROM (SELECT (SELECT name FROM task_priorities WHERE id = new_value::UUID),
|
||||
(SELECT color_code FROM task_priorities WHERE id = new_value::UUID)) rec)
|
||||
ELSE (NULL) END) AS next_priority,
|
||||
|
||||
-- Case for previous phase
|
||||
(CASE
|
||||
WHEN (attribute_type = 'phase' AND old_value <> 'Unmapped')
|
||||
THEN (SELECT ROW_TO_JSON(rec)
|
||||
FROM (SELECT (SELECT name FROM project_phases WHERE id = old_value::UUID),
|
||||
(SELECT color_code FROM project_phases WHERE id = old_value::UUID)) rec)
|
||||
ELSE (NULL) END) AS previous_phase,
|
||||
|
||||
-- Case for next phase
|
||||
(CASE
|
||||
WHEN (attribute_type = 'phase' AND new_value <> 'Unmapped')
|
||||
THEN (SELECT ROW_TO_JSON(rec)
|
||||
FROM (SELECT (SELECT name FROM project_phases WHERE id = new_value::UUID),
|
||||
(SELECT color_code FROM project_phases WHERE id = new_value::UUID)) rec)
|
||||
ELSE (NULL) END) AS next_phase,
|
||||
|
||||
-- Case for done by
|
||||
(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,
|
||||
|
||||
-- Add log text for progress and weight
|
||||
(CASE
|
||||
WHEN (attribute_type = 'progress')
|
||||
THEN 'updated the progress of'
|
||||
WHEN (attribute_type = 'weight')
|
||||
THEN 'updated the weight of'
|
||||
ELSE ''
|
||||
END) AS log_text
|
||||
|
||||
|
||||
FROM task_activity_logs tal
|
||||
WHERE task_id = _task_id
|
||||
ORDER BY created_at DESC) rec2) AS logs) rec;
|
||||
RETURN _result;
|
||||
END;
|
||||
$$;
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,243 @@
|
||||
-- Migration: Update time-based progress mode to work for all tasks
|
||||
-- Date: 2025-04-25
|
||||
-- Version: 1.0.0
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- Update function to use time-based progress for all tasks
|
||||
CREATE OR REPLACE FUNCTION get_task_complete_ratio(_task_id uuid) RETURNS json
|
||||
LANGUAGE plpgsql
|
||||
AS
|
||||
$$
|
||||
DECLARE
|
||||
_parent_task_done FLOAT = 0;
|
||||
_sub_tasks_done FLOAT = 0;
|
||||
_sub_tasks_count FLOAT = 0;
|
||||
_total_completed FLOAT = 0;
|
||||
_total_tasks FLOAT = 0;
|
||||
_ratio FLOAT = 0;
|
||||
_is_manual BOOLEAN = FALSE;
|
||||
_manual_value INTEGER = NULL;
|
||||
_project_id UUID;
|
||||
_use_manual_progress BOOLEAN = FALSE;
|
||||
_use_weighted_progress BOOLEAN = FALSE;
|
||||
_use_time_progress BOOLEAN = FALSE;
|
||||
_task_complete BOOLEAN = FALSE;
|
||||
BEGIN
|
||||
-- Check if manual progress is set for this task
|
||||
SELECT manual_progress, progress_value, project_id,
|
||||
EXISTS(
|
||||
SELECT 1
|
||||
FROM tasks_with_status_view
|
||||
WHERE tasks_with_status_view.task_id = tasks.id
|
||||
AND is_done IS TRUE
|
||||
) AS is_complete
|
||||
FROM tasks
|
||||
WHERE id = _task_id
|
||||
INTO _is_manual, _manual_value, _project_id, _task_complete;
|
||||
|
||||
-- Check if the project uses manual progress
|
||||
IF _project_id IS NOT NULL THEN
|
||||
SELECT COALESCE(use_manual_progress, FALSE),
|
||||
COALESCE(use_weighted_progress, FALSE),
|
||||
COALESCE(use_time_progress, FALSE)
|
||||
FROM projects
|
||||
WHERE id = _project_id
|
||||
INTO _use_manual_progress, _use_weighted_progress, _use_time_progress;
|
||||
END IF;
|
||||
|
||||
-- Get all subtasks
|
||||
SELECT COUNT(*)
|
||||
FROM tasks
|
||||
WHERE parent_task_id = _task_id AND archived IS FALSE
|
||||
INTO _sub_tasks_count;
|
||||
|
||||
-- If task is complete, always return 100%
|
||||
IF _task_complete IS TRUE THEN
|
||||
RETURN JSON_BUILD_OBJECT(
|
||||
'ratio', 100,
|
||||
'total_completed', 1,
|
||||
'total_tasks', 1,
|
||||
'is_manual', FALSE
|
||||
);
|
||||
END IF;
|
||||
|
||||
-- Use manual progress value in two cases:
|
||||
-- 1. When task has manual_progress = TRUE and progress_value is set
|
||||
-- 2. When project has use_manual_progress = TRUE and progress_value is set
|
||||
IF (_is_manual IS TRUE AND _manual_value IS NOT NULL) OR
|
||||
(_use_manual_progress IS TRUE AND _manual_value IS NOT NULL) THEN
|
||||
RETURN JSON_BUILD_OBJECT(
|
||||
'ratio', _manual_value,
|
||||
'total_completed', 0,
|
||||
'total_tasks', 0,
|
||||
'is_manual', TRUE
|
||||
);
|
||||
END IF;
|
||||
|
||||
-- If there are no subtasks, just use the parent task's status (unless in time-based mode)
|
||||
IF _sub_tasks_count = 0 THEN
|
||||
-- Use time-based estimation for tasks without subtasks if enabled
|
||||
IF _use_time_progress IS TRUE THEN
|
||||
-- For time-based tasks without subtasks, we still need some progress calculation
|
||||
-- If the task is completed, return 100%
|
||||
-- Otherwise, use the progress value if set manually, or 0
|
||||
SELECT
|
||||
CASE
|
||||
WHEN _task_complete IS TRUE THEN 100
|
||||
ELSE COALESCE(_manual_value, 0)
|
||||
END
|
||||
INTO _ratio;
|
||||
ELSE
|
||||
-- Traditional calculation for non-time-based tasks
|
||||
SELECT (CASE WHEN _task_complete IS TRUE THEN 1 ELSE 0 END)
|
||||
INTO _parent_task_done;
|
||||
|
||||
_ratio = _parent_task_done * 100;
|
||||
END IF;
|
||||
ELSE
|
||||
-- If project uses manual progress, calculate based on subtask manual progress values
|
||||
IF _use_manual_progress IS TRUE THEN
|
||||
WITH subtask_progress AS (
|
||||
SELECT
|
||||
t.id,
|
||||
t.manual_progress,
|
||||
t.progress_value,
|
||||
EXISTS(
|
||||
SELECT 1
|
||||
FROM tasks_with_status_view
|
||||
WHERE tasks_with_status_view.task_id = t.id
|
||||
AND is_done IS TRUE
|
||||
) AS is_complete
|
||||
FROM tasks t
|
||||
WHERE t.parent_task_id = _task_id
|
||||
AND t.archived IS FALSE
|
||||
),
|
||||
subtask_with_values AS (
|
||||
SELECT
|
||||
CASE
|
||||
-- For completed tasks, always use 100%
|
||||
WHEN is_complete IS TRUE THEN 100
|
||||
-- For tasks with progress value set, use it regardless of manual_progress flag
|
||||
WHEN progress_value IS NOT NULL THEN progress_value
|
||||
-- Default to 0 for incomplete tasks with no progress value
|
||||
ELSE 0
|
||||
END AS progress_value
|
||||
FROM subtask_progress
|
||||
)
|
||||
SELECT COALESCE(AVG(progress_value), 0)
|
||||
FROM subtask_with_values
|
||||
INTO _ratio;
|
||||
-- If project uses weighted progress, calculate based on subtask weights
|
||||
ELSIF _use_weighted_progress IS TRUE THEN
|
||||
WITH subtask_progress AS (
|
||||
SELECT
|
||||
t.id,
|
||||
t.manual_progress,
|
||||
t.progress_value,
|
||||
EXISTS(
|
||||
SELECT 1
|
||||
FROM tasks_with_status_view
|
||||
WHERE tasks_with_status_view.task_id = t.id
|
||||
AND is_done IS TRUE
|
||||
) AS is_complete,
|
||||
COALESCE(t.weight, 100) AS weight
|
||||
FROM tasks t
|
||||
WHERE t.parent_task_id = _task_id
|
||||
AND t.archived IS FALSE
|
||||
),
|
||||
subtask_with_values AS (
|
||||
SELECT
|
||||
CASE
|
||||
-- For completed tasks, always use 100%
|
||||
WHEN is_complete IS TRUE THEN 100
|
||||
-- For tasks with progress value set, use it regardless of manual_progress flag
|
||||
WHEN progress_value IS NOT NULL THEN progress_value
|
||||
-- Default to 0 for incomplete tasks with no progress value
|
||||
ELSE 0
|
||||
END AS progress_value,
|
||||
weight
|
||||
FROM subtask_progress
|
||||
)
|
||||
SELECT COALESCE(
|
||||
SUM(progress_value * weight) / NULLIF(SUM(weight), 0),
|
||||
0
|
||||
)
|
||||
FROM subtask_with_values
|
||||
INTO _ratio;
|
||||
-- If project uses time-based progress, calculate based on estimated time
|
||||
ELSIF _use_time_progress IS TRUE THEN
|
||||
WITH subtask_progress AS (
|
||||
SELECT
|
||||
t.id,
|
||||
t.manual_progress,
|
||||
t.progress_value,
|
||||
EXISTS(
|
||||
SELECT 1
|
||||
FROM tasks_with_status_view
|
||||
WHERE tasks_with_status_view.task_id = t.id
|
||||
AND is_done IS TRUE
|
||||
) AS is_complete,
|
||||
COALESCE(t.total_minutes, 0) AS estimated_minutes
|
||||
FROM tasks t
|
||||
WHERE t.parent_task_id = _task_id
|
||||
AND t.archived IS FALSE
|
||||
),
|
||||
subtask_with_values AS (
|
||||
SELECT
|
||||
CASE
|
||||
-- For completed tasks, always use 100%
|
||||
WHEN is_complete IS TRUE THEN 100
|
||||
-- For tasks with progress value set, use it regardless of manual_progress flag
|
||||
WHEN progress_value IS NOT NULL THEN progress_value
|
||||
-- Default to 0 for incomplete tasks with no progress value
|
||||
ELSE 0
|
||||
END AS progress_value,
|
||||
estimated_minutes
|
||||
FROM subtask_progress
|
||||
)
|
||||
SELECT COALESCE(
|
||||
SUM(progress_value * estimated_minutes) / NULLIF(SUM(estimated_minutes), 0),
|
||||
0
|
||||
)
|
||||
FROM subtask_with_values
|
||||
INTO _ratio;
|
||||
ELSE
|
||||
-- Traditional calculation based on completion status
|
||||
SELECT (CASE WHEN _task_complete IS TRUE THEN 1 ELSE 0 END)
|
||||
INTO _parent_task_done;
|
||||
|
||||
SELECT COUNT(*)
|
||||
FROM tasks_with_status_view
|
||||
WHERE parent_task_id = _task_id
|
||||
AND is_done IS TRUE
|
||||
INTO _sub_tasks_done;
|
||||
|
||||
_total_completed = _parent_task_done + _sub_tasks_done;
|
||||
_total_tasks = _sub_tasks_count + 1; -- +1 for the parent task
|
||||
|
||||
IF _total_tasks = 0 THEN
|
||||
_ratio = 0;
|
||||
ELSE
|
||||
_ratio = (_total_completed / _total_tasks) * 100;
|
||||
END IF;
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
-- Ensure ratio is between 0 and 100
|
||||
IF _ratio < 0 THEN
|
||||
_ratio = 0;
|
||||
ELSIF _ratio > 100 THEN
|
||||
_ratio = 100;
|
||||
END IF;
|
||||
|
||||
RETURN JSON_BUILD_OBJECT(
|
||||
'ratio', _ratio,
|
||||
'total_completed', _total_completed,
|
||||
'total_tasks', _total_tasks,
|
||||
'is_manual', _is_manual
|
||||
);
|
||||
END
|
||||
$$;
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,289 @@
|
||||
-- Migration: Improve parent task progress calculation using weights and time estimation
|
||||
-- Date: 2025-04-26
|
||||
-- Version: 1.0.0
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- Update function to better calculate parent task progress based on subtask weights or time estimations
|
||||
CREATE OR REPLACE FUNCTION get_task_complete_ratio(_task_id uuid) RETURNS json
|
||||
LANGUAGE plpgsql
|
||||
AS
|
||||
$$
|
||||
DECLARE
|
||||
_parent_task_done FLOAT = 0;
|
||||
_sub_tasks_done FLOAT = 0;
|
||||
_sub_tasks_count FLOAT = 0;
|
||||
_total_completed FLOAT = 0;
|
||||
_total_tasks FLOAT = 0;
|
||||
_ratio FLOAT = 0;
|
||||
_is_manual BOOLEAN = FALSE;
|
||||
_manual_value INTEGER = NULL;
|
||||
_project_id UUID;
|
||||
_use_manual_progress BOOLEAN = FALSE;
|
||||
_use_weighted_progress BOOLEAN = FALSE;
|
||||
_use_time_progress BOOLEAN = FALSE;
|
||||
BEGIN
|
||||
-- Check if manual progress is set for this task
|
||||
SELECT manual_progress, progress_value, project_id
|
||||
FROM tasks
|
||||
WHERE id = _task_id
|
||||
INTO _is_manual, _manual_value, _project_id;
|
||||
|
||||
-- Check if the project uses manual progress
|
||||
IF _project_id IS NOT NULL THEN
|
||||
SELECT COALESCE(use_manual_progress, FALSE),
|
||||
COALESCE(use_weighted_progress, FALSE),
|
||||
COALESCE(use_time_progress, FALSE)
|
||||
FROM projects
|
||||
WHERE id = _project_id
|
||||
INTO _use_manual_progress, _use_weighted_progress, _use_time_progress;
|
||||
END IF;
|
||||
|
||||
-- Get all subtasks
|
||||
SELECT COUNT(*)
|
||||
FROM tasks
|
||||
WHERE parent_task_id = _task_id AND archived IS FALSE
|
||||
INTO _sub_tasks_count;
|
||||
|
||||
-- Only respect manual progress for tasks without subtasks
|
||||
IF _is_manual IS TRUE AND _manual_value IS NOT NULL AND _sub_tasks_count = 0 THEN
|
||||
RETURN JSON_BUILD_OBJECT(
|
||||
'ratio', _manual_value,
|
||||
'total_completed', 0,
|
||||
'total_tasks', 0,
|
||||
'is_manual', TRUE
|
||||
);
|
||||
END IF;
|
||||
|
||||
-- If there are no subtasks, just use the parent task's status
|
||||
IF _sub_tasks_count = 0 THEN
|
||||
-- For tasks without subtasks in time-based mode
|
||||
IF _use_time_progress IS TRUE THEN
|
||||
SELECT
|
||||
CASE
|
||||
WHEN EXISTS(
|
||||
SELECT 1
|
||||
FROM tasks_with_status_view
|
||||
WHERE tasks_with_status_view.task_id = _task_id
|
||||
AND is_done IS TRUE
|
||||
) THEN 100
|
||||
ELSE COALESCE(_manual_value, 0)
|
||||
END
|
||||
INTO _ratio;
|
||||
ELSE
|
||||
-- Traditional calculation for non-time-based tasks
|
||||
SELECT (CASE
|
||||
WHEN EXISTS(SELECT 1
|
||||
FROM tasks_with_status_view
|
||||
WHERE tasks_with_status_view.task_id = _task_id
|
||||
AND is_done IS TRUE) THEN 1
|
||||
ELSE 0 END)
|
||||
INTO _parent_task_done;
|
||||
|
||||
_ratio = _parent_task_done * 100;
|
||||
END IF;
|
||||
ELSE
|
||||
-- For parent tasks with subtasks, always use the appropriate calculation based on project mode
|
||||
-- If project uses manual progress, calculate based on subtask manual progress values
|
||||
IF _use_manual_progress IS TRUE THEN
|
||||
WITH subtask_progress AS (
|
||||
SELECT
|
||||
CASE
|
||||
-- If subtask has manual progress, use that value
|
||||
WHEN manual_progress IS TRUE AND progress_value IS NOT NULL THEN
|
||||
progress_value
|
||||
-- Otherwise use completion status (0 or 100)
|
||||
ELSE
|
||||
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 100
|
||||
ELSE 0
|
||||
END
|
||||
END AS progress_value
|
||||
FROM tasks t
|
||||
WHERE t.parent_task_id = _task_id
|
||||
AND t.archived IS FALSE
|
||||
)
|
||||
SELECT COALESCE(AVG(progress_value), 0)
|
||||
FROM subtask_progress
|
||||
INTO _ratio;
|
||||
-- If project uses weighted progress, calculate based on subtask weights
|
||||
ELSIF _use_weighted_progress IS TRUE THEN
|
||||
WITH subtask_progress AS (
|
||||
SELECT
|
||||
CASE
|
||||
-- If subtask has manual progress, use that value
|
||||
WHEN manual_progress IS TRUE AND progress_value IS NOT NULL THEN
|
||||
progress_value
|
||||
-- Otherwise use completion status (0 or 100)
|
||||
ELSE
|
||||
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 100
|
||||
ELSE 0
|
||||
END
|
||||
END AS progress_value,
|
||||
COALESCE(weight, 100) AS weight -- Default weight is 100 if not specified
|
||||
FROM tasks t
|
||||
WHERE t.parent_task_id = _task_id
|
||||
AND t.archived IS FALSE
|
||||
)
|
||||
SELECT COALESCE(
|
||||
SUM(progress_value * weight) / NULLIF(SUM(weight), 0),
|
||||
0
|
||||
)
|
||||
FROM subtask_progress
|
||||
INTO _ratio;
|
||||
-- If project uses time-based progress, calculate based on estimated time (total_minutes)
|
||||
ELSIF _use_time_progress IS TRUE THEN
|
||||
WITH subtask_progress AS (
|
||||
SELECT
|
||||
CASE
|
||||
-- If subtask has manual progress, use that value
|
||||
WHEN manual_progress IS TRUE AND progress_value IS NOT NULL THEN
|
||||
progress_value
|
||||
-- Otherwise use completion status (0 or 100)
|
||||
ELSE
|
||||
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 100
|
||||
ELSE 0
|
||||
END
|
||||
END AS progress_value,
|
||||
COALESCE(total_minutes, 0) AS estimated_minutes -- Use time estimation for weighting
|
||||
FROM tasks t
|
||||
WHERE t.parent_task_id = _task_id
|
||||
AND t.archived IS FALSE
|
||||
)
|
||||
SELECT COALESCE(
|
||||
SUM(progress_value * estimated_minutes) / NULLIF(SUM(estimated_minutes), 0),
|
||||
0
|
||||
)
|
||||
FROM subtask_progress
|
||||
INTO _ratio;
|
||||
ELSE
|
||||
-- Traditional calculation based on completion status when no special mode is enabled
|
||||
SELECT (CASE
|
||||
WHEN EXISTS(SELECT 1
|
||||
FROM tasks_with_status_view
|
||||
WHERE tasks_with_status_view.task_id = _task_id
|
||||
AND is_done IS TRUE) THEN 1
|
||||
ELSE 0 END)
|
||||
INTO _parent_task_done;
|
||||
|
||||
SELECT COUNT(*)
|
||||
FROM tasks_with_status_view
|
||||
WHERE parent_task_id = _task_id
|
||||
AND is_done IS TRUE
|
||||
INTO _sub_tasks_done;
|
||||
|
||||
_total_completed = _parent_task_done + _sub_tasks_done;
|
||||
_total_tasks = _sub_tasks_count + 1; -- +1 for the parent task
|
||||
|
||||
IF _total_tasks = 0 THEN
|
||||
_ratio = 0;
|
||||
ELSE
|
||||
_ratio = (_total_completed / _total_tasks) * 100;
|
||||
END IF;
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
-- Ensure ratio is between 0 and 100
|
||||
IF _ratio < 0 THEN
|
||||
_ratio = 0;
|
||||
ELSIF _ratio > 100 THEN
|
||||
_ratio = 100;
|
||||
END IF;
|
||||
|
||||
RETURN JSON_BUILD_OBJECT(
|
||||
'ratio', _ratio,
|
||||
'total_completed', _total_completed,
|
||||
'total_tasks', _total_tasks,
|
||||
'is_manual', _is_manual
|
||||
);
|
||||
END
|
||||
$$;
|
||||
|
||||
-- Make sure we recalculate parent task progress when subtask progress changes
|
||||
CREATE OR REPLACE FUNCTION update_parent_task_progress() RETURNS TRIGGER AS
|
||||
$$
|
||||
DECLARE
|
||||
_parent_task_id UUID;
|
||||
_project_id UUID;
|
||||
_ratio FLOAT;
|
||||
BEGIN
|
||||
-- Check if this is a subtask
|
||||
IF NEW.parent_task_id IS NOT NULL THEN
|
||||
_parent_task_id := NEW.parent_task_id;
|
||||
|
||||
-- Force any parent task with subtasks to NOT use manual progress
|
||||
UPDATE tasks
|
||||
SET manual_progress = FALSE
|
||||
WHERE id = _parent_task_id;
|
||||
END IF;
|
||||
|
||||
-- If this task has progress value of 100 and doesn't have subtasks, we might want to prompt the user
|
||||
-- to mark it as done. We'll annotate this in a way that the socket handler can detect.
|
||||
IF NEW.progress_value = 100 OR NEW.weight = 100 OR NEW.total_minutes > 0 THEN
|
||||
-- Check if task has status in "done" category
|
||||
SELECT project_id FROM tasks WHERE id = NEW.id INTO _project_id;
|
||||
|
||||
-- Get the progress ratio for this task
|
||||
SELECT get_task_complete_ratio(NEW.id)->>'ratio' INTO _ratio;
|
||||
|
||||
IF _ratio::FLOAT >= 100 THEN
|
||||
-- Log that this task is at 100% progress
|
||||
RAISE NOTICE 'Task % progress is at 100%%, may need status update', NEW.id;
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Create trigger for updates to task progress
|
||||
DROP TRIGGER IF EXISTS update_parent_task_progress_trigger ON tasks;
|
||||
CREATE TRIGGER update_parent_task_progress_trigger
|
||||
AFTER UPDATE OF progress_value, weight, total_minutes ON tasks
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_parent_task_progress();
|
||||
|
||||
-- Create a function to ensure parent tasks never have manual progress when they have subtasks
|
||||
CREATE OR REPLACE FUNCTION ensure_parent_task_without_manual_progress() RETURNS TRIGGER AS
|
||||
$$
|
||||
BEGIN
|
||||
-- If this is a new subtask being created or a task is being converted to a subtask
|
||||
IF NEW.parent_task_id IS NOT NULL THEN
|
||||
-- Force the parent task to NOT use manual progress
|
||||
UPDATE tasks
|
||||
SET manual_progress = FALSE
|
||||
WHERE id = NEW.parent_task_id;
|
||||
|
||||
-- Log that we've reset manual progress for a parent task
|
||||
RAISE NOTICE 'Reset manual progress for parent task % because it has subtasks', NEW.parent_task_id;
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Create trigger for when tasks are created or updated with a parent_task_id
|
||||
DROP TRIGGER IF EXISTS ensure_parent_task_without_manual_progress_trigger ON tasks;
|
||||
CREATE TRIGGER ensure_parent_task_without_manual_progress_trigger
|
||||
AFTER INSERT OR UPDATE OF parent_task_id ON tasks
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION ensure_parent_task_without_manual_progress();
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,150 @@
|
||||
-- Migration: Update socket event handlers to set progress-mode handlers
|
||||
-- Date: 2025-04-26
|
||||
-- Version: 1.0.0
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- Create ENUM type for progress modes
|
||||
CREATE TYPE progress_mode_type AS ENUM ('manual', 'weighted', 'time', 'default');
|
||||
|
||||
-- Alter tasks table to use ENUM type
|
||||
ALTER TABLE tasks
|
||||
ALTER COLUMN progress_mode TYPE progress_mode_type
|
||||
USING progress_mode::text::progress_mode_type;
|
||||
|
||||
-- Update the on_update_task_progress function to set progress_mode
|
||||
CREATE OR REPLACE FUNCTION on_update_task_progress(_body json) RETURNS json
|
||||
LANGUAGE plpgsql
|
||||
AS
|
||||
$$
|
||||
DECLARE
|
||||
_task_id UUID;
|
||||
_progress_value INTEGER;
|
||||
_parent_task_id UUID;
|
||||
_project_id UUID;
|
||||
_current_mode progress_mode_type;
|
||||
BEGIN
|
||||
_task_id = (_body ->> 'task_id')::UUID;
|
||||
_progress_value = (_body ->> 'progress_value')::INTEGER;
|
||||
_parent_task_id = (_body ->> 'parent_task_id')::UUID;
|
||||
|
||||
-- Get the project ID and determine the current progress mode
|
||||
SELECT project_id INTO _project_id FROM tasks WHERE id = _task_id;
|
||||
|
||||
IF _project_id IS NOT NULL THEN
|
||||
SELECT
|
||||
CASE
|
||||
WHEN use_manual_progress IS TRUE THEN 'manual'
|
||||
WHEN use_weighted_progress IS TRUE THEN 'weighted'
|
||||
WHEN use_time_progress IS TRUE THEN 'time'
|
||||
ELSE 'default'
|
||||
END
|
||||
INTO _current_mode
|
||||
FROM projects
|
||||
WHERE id = _project_id;
|
||||
ELSE
|
||||
_current_mode := 'default';
|
||||
END IF;
|
||||
|
||||
-- Update the task with progress value and set the progress mode
|
||||
UPDATE tasks
|
||||
SET progress_value = _progress_value,
|
||||
manual_progress = TRUE,
|
||||
progress_mode = _current_mode,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = _task_id;
|
||||
|
||||
-- Return the updated task info
|
||||
RETURN JSON_BUILD_OBJECT(
|
||||
'task_id', _task_id,
|
||||
'progress_value', _progress_value,
|
||||
'progress_mode', _current_mode
|
||||
);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Update the on_update_task_weight function to set progress_mode when weight is updated
|
||||
CREATE OR REPLACE FUNCTION on_update_task_weight(_body json) RETURNS json
|
||||
LANGUAGE plpgsql
|
||||
AS
|
||||
$$
|
||||
DECLARE
|
||||
_task_id UUID;
|
||||
_weight INTEGER;
|
||||
_parent_task_id UUID;
|
||||
_project_id UUID;
|
||||
BEGIN
|
||||
_task_id = (_body ->> 'task_id')::UUID;
|
||||
_weight = (_body ->> 'weight')::INTEGER;
|
||||
_parent_task_id = (_body ->> 'parent_task_id')::UUID;
|
||||
|
||||
-- Get the project ID
|
||||
SELECT project_id INTO _project_id FROM tasks WHERE id = _task_id;
|
||||
|
||||
-- Update the task with weight value and set progress_mode to 'weighted'
|
||||
UPDATE tasks
|
||||
SET weight = _weight,
|
||||
progress_mode = 'weighted',
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = _task_id;
|
||||
|
||||
-- Return the updated task info
|
||||
RETURN JSON_BUILD_OBJECT(
|
||||
'task_id', _task_id,
|
||||
'weight', _weight
|
||||
);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Create a function to reset progress values when switching project progress modes
|
||||
CREATE OR REPLACE FUNCTION reset_project_progress_values() RETURNS TRIGGER
|
||||
LANGUAGE plpgsql
|
||||
AS
|
||||
$$
|
||||
DECLARE
|
||||
_old_mode progress_mode_type;
|
||||
_new_mode progress_mode_type;
|
||||
_project_id UUID;
|
||||
BEGIN
|
||||
_project_id := NEW.id;
|
||||
|
||||
-- Determine old and new modes
|
||||
_old_mode :=
|
||||
CASE
|
||||
WHEN OLD.use_manual_progress IS TRUE THEN 'manual'
|
||||
WHEN OLD.use_weighted_progress IS TRUE THEN 'weighted'
|
||||
WHEN OLD.use_time_progress IS TRUE THEN 'time'
|
||||
ELSE 'default'
|
||||
END;
|
||||
|
||||
_new_mode :=
|
||||
CASE
|
||||
WHEN NEW.use_manual_progress IS TRUE THEN 'manual'
|
||||
WHEN NEW.use_weighted_progress IS TRUE THEN 'weighted'
|
||||
WHEN NEW.use_time_progress IS TRUE THEN 'time'
|
||||
ELSE 'default'
|
||||
END;
|
||||
|
||||
-- If mode has changed, reset progress values for tasks with the old mode
|
||||
IF _old_mode <> _new_mode THEN
|
||||
-- Reset progress values for tasks that were set in the old mode
|
||||
UPDATE tasks
|
||||
SET progress_value = NULL,
|
||||
progress_mode = NULL
|
||||
WHERE project_id = _project_id
|
||||
AND progress_mode = _old_mode;
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Create trigger to reset progress values when project progress mode changes
|
||||
DROP TRIGGER IF EXISTS reset_progress_on_mode_change ON projects;
|
||||
CREATE TRIGGER reset_progress_on_mode_change
|
||||
AFTER UPDATE OF use_manual_progress, use_weighted_progress, use_time_progress
|
||||
ON projects
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION reset_project_progress_values();
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,160 @@
|
||||
-- Migration: Fix progress_mode_type ENUM and casting issues
|
||||
-- Date: 2025-04-27
|
||||
-- Version: 1.0.0
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- First, let's ensure the ENUM type exists with the correct values
|
||||
DO $$
|
||||
BEGIN
|
||||
-- Check if the type exists
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'progress_mode_type') THEN
|
||||
CREATE TYPE progress_mode_type AS ENUM ('manual', 'weighted', 'time', 'default');
|
||||
ELSE
|
||||
-- Add any missing values to the existing ENUM
|
||||
BEGIN
|
||||
ALTER TYPE progress_mode_type ADD VALUE IF NOT EXISTS 'manual';
|
||||
ALTER TYPE progress_mode_type ADD VALUE IF NOT EXISTS 'weighted';
|
||||
ALTER TYPE progress_mode_type ADD VALUE IF NOT EXISTS 'time';
|
||||
ALTER TYPE progress_mode_type ADD VALUE IF NOT EXISTS 'default';
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN
|
||||
-- Ignore if values already exist
|
||||
NULL;
|
||||
END;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- Update functions to use proper type casting
|
||||
CREATE OR REPLACE FUNCTION on_update_task_progress(_body json) RETURNS json
|
||||
LANGUAGE plpgsql
|
||||
AS
|
||||
$$
|
||||
DECLARE
|
||||
_task_id UUID;
|
||||
_progress_value INTEGER;
|
||||
_parent_task_id UUID;
|
||||
_project_id UUID;
|
||||
_current_mode progress_mode_type;
|
||||
BEGIN
|
||||
_task_id = (_body ->> 'task_id')::UUID;
|
||||
_progress_value = (_body ->> 'progress_value')::INTEGER;
|
||||
_parent_task_id = (_body ->> 'parent_task_id')::UUID;
|
||||
|
||||
-- Get the project ID and determine the current progress mode
|
||||
SELECT project_id INTO _project_id FROM tasks WHERE id = _task_id;
|
||||
|
||||
IF _project_id IS NOT NULL THEN
|
||||
SELECT
|
||||
CASE
|
||||
WHEN use_manual_progress IS TRUE THEN 'manual'::progress_mode_type
|
||||
WHEN use_weighted_progress IS TRUE THEN 'weighted'::progress_mode_type
|
||||
WHEN use_time_progress IS TRUE THEN 'time'::progress_mode_type
|
||||
ELSE 'default'::progress_mode_type
|
||||
END
|
||||
INTO _current_mode
|
||||
FROM projects
|
||||
WHERE id = _project_id;
|
||||
ELSE
|
||||
_current_mode := 'default'::progress_mode_type;
|
||||
END IF;
|
||||
|
||||
-- Update the task with progress value and set the progress mode
|
||||
UPDATE tasks
|
||||
SET progress_value = _progress_value,
|
||||
manual_progress = TRUE,
|
||||
progress_mode = _current_mode,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = _task_id;
|
||||
|
||||
-- Return the updated task info
|
||||
RETURN JSON_BUILD_OBJECT(
|
||||
'task_id', _task_id,
|
||||
'progress_value', _progress_value,
|
||||
'progress_mode', _current_mode
|
||||
);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Update the on_update_task_weight function to use proper type casting
|
||||
CREATE OR REPLACE FUNCTION on_update_task_weight(_body json) RETURNS json
|
||||
LANGUAGE plpgsql
|
||||
AS
|
||||
$$
|
||||
DECLARE
|
||||
_task_id UUID;
|
||||
_weight INTEGER;
|
||||
_parent_task_id UUID;
|
||||
_project_id UUID;
|
||||
BEGIN
|
||||
_task_id = (_body ->> 'task_id')::UUID;
|
||||
_weight = (_body ->> 'weight')::INTEGER;
|
||||
_parent_task_id = (_body ->> 'parent_task_id')::UUID;
|
||||
|
||||
-- Get the project ID
|
||||
SELECT project_id INTO _project_id FROM tasks WHERE id = _task_id;
|
||||
|
||||
-- Update the task with weight value and set progress_mode to 'weighted'
|
||||
UPDATE tasks
|
||||
SET weight = _weight,
|
||||
progress_mode = 'weighted'::progress_mode_type,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = _task_id;
|
||||
|
||||
-- Return the updated task info
|
||||
RETURN JSON_BUILD_OBJECT(
|
||||
'task_id', _task_id,
|
||||
'weight', _weight
|
||||
);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Update the reset_project_progress_values function to use proper type casting
|
||||
CREATE OR REPLACE FUNCTION reset_project_progress_values() RETURNS TRIGGER
|
||||
LANGUAGE plpgsql
|
||||
AS
|
||||
$$
|
||||
DECLARE
|
||||
_old_mode progress_mode_type;
|
||||
_new_mode progress_mode_type;
|
||||
_project_id UUID;
|
||||
BEGIN
|
||||
_project_id := NEW.id;
|
||||
|
||||
-- Determine old and new modes with proper type casting
|
||||
_old_mode :=
|
||||
CASE
|
||||
WHEN OLD.use_manual_progress IS TRUE THEN 'manual'::progress_mode_type
|
||||
WHEN OLD.use_weighted_progress IS TRUE THEN 'weighted'::progress_mode_type
|
||||
WHEN OLD.use_time_progress IS TRUE THEN 'time'::progress_mode_type
|
||||
ELSE 'default'::progress_mode_type
|
||||
END;
|
||||
|
||||
_new_mode :=
|
||||
CASE
|
||||
WHEN NEW.use_manual_progress IS TRUE THEN 'manual'::progress_mode_type
|
||||
WHEN NEW.use_weighted_progress IS TRUE THEN 'weighted'::progress_mode_type
|
||||
WHEN NEW.use_time_progress IS TRUE THEN 'time'::progress_mode_type
|
||||
ELSE 'default'::progress_mode_type
|
||||
END;
|
||||
|
||||
-- If mode has changed, reset progress values for tasks with the old mode
|
||||
IF _old_mode <> _new_mode THEN
|
||||
-- Reset progress values for tasks that were set in the old mode
|
||||
UPDATE tasks
|
||||
SET progress_value = NULL,
|
||||
progress_mode = NULL
|
||||
WHERE project_id = _project_id
|
||||
AND progress_mode = _old_mode;
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Update the tasks table to ensure proper type casting for existing values
|
||||
UPDATE tasks
|
||||
SET progress_mode = progress_mode::text::progress_mode_type
|
||||
WHERE progress_mode IS NOT NULL;
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,166 @@
|
||||
-- Migration: Fix multilevel subtask progress calculation for weighted and manual progress
|
||||
-- Date: 2025-05-06
|
||||
-- Version: 1.0.0
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- Update the trigger function to recursively recalculate parent task progress up the entire hierarchy
|
||||
CREATE OR REPLACE FUNCTION update_parent_task_progress() RETURNS TRIGGER AS
|
||||
$$
|
||||
DECLARE
|
||||
_parent_task_id UUID;
|
||||
_project_id UUID;
|
||||
_ratio FLOAT;
|
||||
BEGIN
|
||||
-- Check if this is a subtask
|
||||
IF NEW.parent_task_id IS NOT NULL THEN
|
||||
_parent_task_id := NEW.parent_task_id;
|
||||
|
||||
-- Force any parent task with subtasks to NOT use manual progress
|
||||
UPDATE tasks
|
||||
SET manual_progress = FALSE
|
||||
WHERE id = _parent_task_id;
|
||||
|
||||
-- Calculate and update the parent's progress value
|
||||
SELECT (get_task_complete_ratio(_parent_task_id)->>'ratio')::FLOAT INTO _ratio;
|
||||
|
||||
-- Update the parent's progress value
|
||||
UPDATE tasks
|
||||
SET progress_value = _ratio
|
||||
WHERE id = _parent_task_id;
|
||||
|
||||
-- Recursively propagate changes up the hierarchy by using a recursive CTE
|
||||
WITH RECURSIVE task_hierarchy AS (
|
||||
-- Base case: Start with the parent task
|
||||
SELECT
|
||||
id,
|
||||
parent_task_id
|
||||
FROM tasks
|
||||
WHERE id = _parent_task_id
|
||||
|
||||
UNION ALL
|
||||
|
||||
-- Recursive case: Go up to each ancestor
|
||||
SELECT
|
||||
t.id,
|
||||
t.parent_task_id
|
||||
FROM tasks t
|
||||
JOIN task_hierarchy th ON t.id = th.parent_task_id
|
||||
WHERE t.id IS NOT NULL
|
||||
)
|
||||
-- For each ancestor, recalculate its progress
|
||||
UPDATE tasks
|
||||
SET
|
||||
manual_progress = FALSE,
|
||||
progress_value = (SELECT (get_task_complete_ratio(task_hierarchy.id)->>'ratio')::FLOAT)
|
||||
FROM task_hierarchy
|
||||
WHERE tasks.id = task_hierarchy.id
|
||||
AND task_hierarchy.parent_task_id IS NOT NULL;
|
||||
|
||||
-- Log the recalculation for debugging
|
||||
RAISE NOTICE 'Updated progress for task % to %', _parent_task_id, _ratio;
|
||||
END IF;
|
||||
|
||||
-- If this task has progress value of 100 and doesn't have subtasks, we might want to prompt the user
|
||||
-- to mark it as done. We'll annotate this in a way that the socket handler can detect.
|
||||
IF NEW.progress_value = 100 OR NEW.weight = 100 OR NEW.total_minutes > 0 THEN
|
||||
-- Check if task has status in "done" category
|
||||
SELECT project_id FROM tasks WHERE id = NEW.id INTO _project_id;
|
||||
|
||||
-- Get the progress ratio for this task
|
||||
SELECT (get_task_complete_ratio(NEW.id)->>'ratio')::FLOAT INTO _ratio;
|
||||
|
||||
IF _ratio >= 100 THEN
|
||||
-- Log that this task is at 100% progress
|
||||
RAISE NOTICE 'Task % progress is at 100%%, may need status update', NEW.id;
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Update existing trigger or create a new one to handle more changes
|
||||
DROP TRIGGER IF EXISTS update_parent_task_progress_trigger ON tasks;
|
||||
CREATE TRIGGER update_parent_task_progress_trigger
|
||||
AFTER UPDATE OF progress_value, weight, total_minutes, parent_task_id, manual_progress ON tasks
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_parent_task_progress();
|
||||
|
||||
-- Also add a trigger for when a new task is inserted
|
||||
DROP TRIGGER IF EXISTS update_parent_task_progress_on_insert_trigger ON tasks;
|
||||
CREATE TRIGGER update_parent_task_progress_on_insert_trigger
|
||||
AFTER INSERT ON tasks
|
||||
FOR EACH ROW
|
||||
WHEN (NEW.parent_task_id IS NOT NULL)
|
||||
EXECUTE FUNCTION update_parent_task_progress();
|
||||
|
||||
-- Add a comment to explain the fix
|
||||
COMMENT ON FUNCTION update_parent_task_progress() IS
|
||||
'This function recursively updates progress values for all ancestors when a task''s progress changes.
|
||||
The previous version only updated the immediate parent, which led to incorrect progress values for
|
||||
higher-level parent tasks when using weighted or manual progress calculations with multi-level subtasks.';
|
||||
|
||||
-- Add a function to immediately recalculate all task progress values in the correct order
|
||||
-- This will fix existing data where parent tasks don't have proper progress values
|
||||
CREATE OR REPLACE FUNCTION recalculate_all_task_progress() RETURNS void AS
|
||||
$$
|
||||
BEGIN
|
||||
-- First, reset manual_progress flag for all tasks that have subtasks
|
||||
UPDATE tasks AS t
|
||||
SET manual_progress = FALSE
|
||||
WHERE EXISTS (
|
||||
SELECT 1
|
||||
FROM tasks
|
||||
WHERE parent_task_id = t.id
|
||||
AND archived IS FALSE
|
||||
);
|
||||
|
||||
-- Start recalculation from leaf tasks (no subtasks) and propagate upward
|
||||
-- This ensures calculations are done in the right order
|
||||
WITH RECURSIVE task_hierarchy AS (
|
||||
-- Base case: Start with all leaf tasks (no subtasks)
|
||||
SELECT
|
||||
id,
|
||||
parent_task_id,
|
||||
0 AS level
|
||||
FROM tasks
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM tasks AS sub
|
||||
WHERE sub.parent_task_id = tasks.id
|
||||
AND sub.archived IS FALSE
|
||||
)
|
||||
AND archived IS FALSE
|
||||
|
||||
UNION ALL
|
||||
|
||||
-- Recursive case: Move up to parent tasks, but only after processing all their children
|
||||
SELECT
|
||||
t.id,
|
||||
t.parent_task_id,
|
||||
th.level + 1
|
||||
FROM tasks t
|
||||
JOIN task_hierarchy th ON t.id = th.parent_task_id
|
||||
WHERE t.archived IS FALSE
|
||||
)
|
||||
-- Sort by level to ensure we calculate in the right order (leaves first, then parents)
|
||||
-- This ensures we're using already updated progress values
|
||||
UPDATE tasks
|
||||
SET progress_value = (SELECT (get_task_complete_ratio(tasks.id)->>'ratio')::FLOAT)
|
||||
FROM (
|
||||
SELECT id, level
|
||||
FROM task_hierarchy
|
||||
ORDER BY level
|
||||
) AS ordered_tasks
|
||||
WHERE tasks.id = ordered_tasks.id
|
||||
AND (manual_progress IS FALSE OR manual_progress IS NULL);
|
||||
|
||||
-- Log the completion of the recalculation
|
||||
RAISE NOTICE 'Finished recalculating all task progress values';
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Execute the function to fix existing data
|
||||
SELECT recalculate_all_task_progress();
|
||||
|
||||
COMMIT;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3351,15 +3351,15 @@ BEGIN
|
||||
SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(rec))), '[]'::JSON)
|
||||
FROM (SELECT team_member_id,
|
||||
project_member_id,
|
||||
(SELECT name FROM team_member_info_view WHERE team_member_info_view.team_member_id = tm.id),
|
||||
(SELECT email_notifications_enabled
|
||||
COALESCE((SELECT name FROM team_member_info_view WHERE team_member_info_view.team_member_id = tm.id), '') as name,
|
||||
COALESCE((SELECT email_notifications_enabled
|
||||
FROM notification_settings
|
||||
WHERE team_id = tm.team_id
|
||||
AND notification_settings.user_id = u.id) AS email_notifications_enabled,
|
||||
u.avatar_url,
|
||||
AND notification_settings.user_id = u.id), false) AS email_notifications_enabled,
|
||||
COALESCE(u.avatar_url, '') as avatar_url,
|
||||
u.id AS user_id,
|
||||
u.email,
|
||||
u.socket_id AS socket_id,
|
||||
COALESCE(u.email, '') as email,
|
||||
COALESCE(u.socket_id, '') as socket_id,
|
||||
tm.team_id AS team_id
|
||||
FROM tasks_assignees
|
||||
INNER JOIN team_members tm ON tm.id = tasks_assignees.team_member_id
|
||||
@@ -4066,14 +4066,14 @@ DECLARE
|
||||
_schedule_id JSON;
|
||||
_task_completed_at TIMESTAMPTZ;
|
||||
BEGIN
|
||||
SELECT name FROM tasks WHERE id = _task_id INTO _task_name;
|
||||
SELECT COALESCE(name, '') FROM tasks WHERE id = _task_id INTO _task_name;
|
||||
|
||||
SELECT name
|
||||
SELECT COALESCE(name, '')
|
||||
FROM task_statuses
|
||||
WHERE id = (SELECT status_id FROM tasks WHERE id = _task_id)
|
||||
INTO _previous_status_name;
|
||||
|
||||
SELECT name FROM task_statuses WHERE id = _status_id INTO _new_status_name;
|
||||
SELECT COALESCE(name, '') FROM task_statuses WHERE id = _status_id INTO _new_status_name;
|
||||
|
||||
IF (_previous_status_name != _new_status_name)
|
||||
THEN
|
||||
@@ -4081,14 +4081,22 @@ BEGIN
|
||||
|
||||
SELECT get_task_complete_info(_task_id, _status_id) INTO _task_info;
|
||||
|
||||
SELECT name FROM users WHERE id = _user_id INTO _updater_name;
|
||||
SELECT COALESCE(name, '') FROM users WHERE id = _user_id INTO _updater_name;
|
||||
|
||||
_message = CONCAT(_updater_name, ' transitioned "', _task_name, '" from ', _previous_status_name, ' ⟶ ',
|
||||
_new_status_name);
|
||||
END IF;
|
||||
|
||||
SELECT completed_at FROM tasks WHERE id = _task_id INTO _task_completed_at;
|
||||
SELECT schedule_id FROM tasks WHERE id = _task_id INTO _schedule_id;
|
||||
|
||||
-- Handle schedule_id properly for recurring tasks
|
||||
SELECT CASE
|
||||
WHEN schedule_id IS NULL THEN 'null'::json
|
||||
ELSE json_build_object('id', schedule_id)
|
||||
END
|
||||
FROM tasks
|
||||
WHERE id = _task_id
|
||||
INTO _schedule_id;
|
||||
|
||||
SELECT COALESCE(ROW_TO_JSON(r), '{}'::JSON)
|
||||
FROM (SELECT is_done, is_doing, is_todo
|
||||
@@ -4097,7 +4105,7 @@ BEGIN
|
||||
INTO _status_category;
|
||||
|
||||
RETURN JSON_BUILD_OBJECT(
|
||||
'message', _message,
|
||||
'message', COALESCE(_message, ''),
|
||||
'project_id', (SELECT project_id FROM tasks WHERE id = _task_id),
|
||||
'parent_done', (CASE
|
||||
WHEN EXISTS(SELECT 1
|
||||
@@ -4105,14 +4113,14 @@ BEGIN
|
||||
WHERE tasks_with_status_view.task_id = _task_id
|
||||
AND is_done IS TRUE) THEN 1
|
||||
ELSE 0 END),
|
||||
'color_code', (_task_info ->> 'color_code')::TEXT,
|
||||
'color_code_dark', (_task_info ->> 'color_code_dark')::TEXT,
|
||||
'total_tasks', (_task_info ->> 'total_tasks')::INT,
|
||||
'total_completed', (_task_info ->> 'total_completed')::INT,
|
||||
'members', (_task_info ->> 'members')::JSON,
|
||||
'color_code', COALESCE((_task_info ->> 'color_code')::TEXT, ''),
|
||||
'color_code_dark', COALESCE((_task_info ->> 'color_code_dark')::TEXT, ''),
|
||||
'total_tasks', COALESCE((_task_info ->> 'total_tasks')::INT, 0),
|
||||
'total_completed', COALESCE((_task_info ->> 'total_completed')::INT, 0),
|
||||
'members', COALESCE((_task_info ->> 'members')::JSON, '[]'::JSON),
|
||||
'completed_at', _task_completed_at,
|
||||
'status_category', _status_category,
|
||||
'schedule_id', _schedule_id
|
||||
'status_category', COALESCE(_status_category, '{}'::JSON),
|
||||
'schedule_id', COALESCE(_schedule_id, 'null'::JSON)
|
||||
);
|
||||
END
|
||||
$$;
|
||||
@@ -6148,3 +6156,219 @@ BEGIN
|
||||
RETURN v_new_id;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION transfer_team_ownership(_team_id UUID, _new_owner_id UUID) RETURNS json
|
||||
LANGUAGE plpgsql
|
||||
AS
|
||||
$$
|
||||
DECLARE
|
||||
_old_owner_id UUID;
|
||||
_owner_role_id UUID;
|
||||
_admin_role_id UUID;
|
||||
_old_org_id UUID;
|
||||
_new_org_id UUID;
|
||||
_has_license BOOLEAN;
|
||||
_old_owner_role_id UUID;
|
||||
_new_owner_role_id UUID;
|
||||
_has_active_coupon BOOLEAN;
|
||||
_other_teams_count INTEGER;
|
||||
_new_owner_org_id UUID;
|
||||
_license_type_id UUID;
|
||||
_has_valid_license BOOLEAN;
|
||||
BEGIN
|
||||
-- Get the current owner's ID and organization
|
||||
SELECT t.user_id, t.organization_id
|
||||
INTO _old_owner_id, _old_org_id
|
||||
FROM teams t
|
||||
WHERE t.id = _team_id;
|
||||
|
||||
IF _old_owner_id IS NULL THEN
|
||||
RAISE EXCEPTION 'Team not found';
|
||||
END IF;
|
||||
|
||||
-- Get the new owner's organization
|
||||
SELECT organization_id INTO _new_owner_org_id
|
||||
FROM organizations
|
||||
WHERE user_id = _new_owner_id;
|
||||
|
||||
-- Get the old organization
|
||||
SELECT id INTO _old_org_id
|
||||
FROM organizations
|
||||
WHERE id = _old_org_id;
|
||||
|
||||
IF _old_org_id IS NULL THEN
|
||||
RAISE EXCEPTION 'Organization not found';
|
||||
END IF;
|
||||
|
||||
-- Check if new owner has any valid license type
|
||||
SELECT EXISTS (
|
||||
SELECT 1
|
||||
FROM (
|
||||
-- Check regular subscriptions
|
||||
SELECT lus.user_id, lus.status, lus.active
|
||||
FROM licensing_user_subscriptions lus
|
||||
WHERE lus.user_id = _new_owner_id
|
||||
AND lus.active = TRUE
|
||||
AND lus.status IN ('active', 'trialing')
|
||||
|
||||
UNION ALL
|
||||
|
||||
-- Check custom subscriptions
|
||||
SELECT lcs.user_id, lcs.subscription_status as status, TRUE as active
|
||||
FROM licensing_custom_subs lcs
|
||||
WHERE lcs.user_id = _new_owner_id
|
||||
AND lcs.end_date > CURRENT_DATE
|
||||
|
||||
UNION ALL
|
||||
|
||||
-- Check trial status in organizations
|
||||
SELECT o.user_id, o.subscription_status as status, TRUE as active
|
||||
FROM organizations o
|
||||
WHERE o.user_id = _new_owner_id
|
||||
AND o.trial_in_progress = TRUE
|
||||
AND o.trial_expire_date > CURRENT_DATE
|
||||
) valid_licenses
|
||||
) INTO _has_valid_license;
|
||||
|
||||
IF NOT _has_valid_license THEN
|
||||
RAISE EXCEPTION 'New owner does not have a valid license (subscription, custom subscription, or trial)';
|
||||
END IF;
|
||||
|
||||
-- Check if new owner has any active coupon codes
|
||||
SELECT EXISTS (
|
||||
SELECT 1
|
||||
FROM licensing_coupon_codes lcc
|
||||
WHERE lcc.redeemed_by = _new_owner_id
|
||||
AND lcc.is_redeemed = TRUE
|
||||
AND lcc.is_refunded = FALSE
|
||||
) INTO _has_active_coupon;
|
||||
|
||||
IF _has_active_coupon THEN
|
||||
RAISE EXCEPTION 'New owner has active coupon codes that need to be handled before transfer';
|
||||
END IF;
|
||||
|
||||
-- Count other teams in the organization for information purposes
|
||||
SELECT COUNT(*) INTO _other_teams_count
|
||||
FROM teams
|
||||
WHERE organization_id = _old_org_id
|
||||
AND id != _team_id;
|
||||
|
||||
-- If new owner has their own organization, move the team to their organization
|
||||
IF _new_owner_org_id IS NOT NULL THEN
|
||||
-- Update the team to use the new owner's organization
|
||||
UPDATE teams
|
||||
SET user_id = _new_owner_id,
|
||||
organization_id = _new_owner_org_id
|
||||
WHERE id = _team_id;
|
||||
|
||||
-- Create notification about organization change
|
||||
PERFORM create_notification(
|
||||
_old_owner_id,
|
||||
_team_id,
|
||||
NULL,
|
||||
NULL,
|
||||
CONCAT('Team <b>', (SELECT name FROM teams WHERE id = _team_id), '</b> has been moved to a different organization')
|
||||
);
|
||||
|
||||
PERFORM create_notification(
|
||||
_new_owner_id,
|
||||
_team_id,
|
||||
NULL,
|
||||
NULL,
|
||||
CONCAT('Team <b>', (SELECT name FROM teams WHERE id = _team_id), '</b> has been moved to your organization')
|
||||
);
|
||||
ELSE
|
||||
-- If new owner doesn't have an organization, transfer the old organization to them
|
||||
UPDATE organizations
|
||||
SET user_id = _new_owner_id
|
||||
WHERE id = _old_org_id;
|
||||
|
||||
-- Update the team to use the same organization
|
||||
UPDATE teams
|
||||
SET user_id = _new_owner_id,
|
||||
organization_id = _old_org_id
|
||||
WHERE id = _team_id;
|
||||
|
||||
-- Notify both users about organization ownership transfer
|
||||
PERFORM create_notification(
|
||||
_old_owner_id,
|
||||
NULL,
|
||||
NULL,
|
||||
NULL,
|
||||
CONCAT('You are no longer the owner of organization <b>', (SELECT organization_name FROM organizations WHERE id = _old_org_id), '</b>')
|
||||
);
|
||||
|
||||
PERFORM create_notification(
|
||||
_new_owner_id,
|
||||
NULL,
|
||||
NULL,
|
||||
NULL,
|
||||
CONCAT('You are now the owner of organization <b>', (SELECT organization_name FROM organizations WHERE id = _old_org_id), '</b>')
|
||||
);
|
||||
END IF;
|
||||
|
||||
-- Get the owner and admin role IDs
|
||||
SELECT id INTO _owner_role_id FROM roles WHERE team_id = _team_id AND owner = TRUE;
|
||||
SELECT id INTO _admin_role_id FROM roles WHERE team_id = _team_id AND admin_role = TRUE;
|
||||
|
||||
-- Get current role IDs for both users
|
||||
SELECT role_id INTO _old_owner_role_id
|
||||
FROM team_members
|
||||
WHERE team_id = _team_id AND user_id = _old_owner_id;
|
||||
|
||||
SELECT role_id INTO _new_owner_role_id
|
||||
FROM team_members
|
||||
WHERE team_id = _team_id AND user_id = _new_owner_id;
|
||||
|
||||
-- Update the old owner's role to admin if they want to stay in the team
|
||||
IF _old_owner_role_id IS NOT NULL THEN
|
||||
UPDATE team_members
|
||||
SET role_id = _admin_role_id
|
||||
WHERE team_id = _team_id AND user_id = _old_owner_id;
|
||||
END IF;
|
||||
|
||||
-- Update the new owner's role to owner
|
||||
IF _new_owner_role_id IS NOT NULL THEN
|
||||
UPDATE team_members
|
||||
SET role_id = _owner_role_id
|
||||
WHERE team_id = _team_id AND user_id = _new_owner_id;
|
||||
ELSE
|
||||
-- If new owner is not a team member yet, add them
|
||||
INSERT INTO team_members (user_id, team_id, role_id)
|
||||
VALUES (_new_owner_id, _team_id, _owner_role_id);
|
||||
END IF;
|
||||
|
||||
-- Create notification for both users about team ownership
|
||||
PERFORM create_notification(
|
||||
_old_owner_id,
|
||||
_team_id,
|
||||
NULL,
|
||||
NULL,
|
||||
CONCAT('You are no longer the owner of team <b>', (SELECT name FROM teams WHERE id = _team_id), '</b>')
|
||||
);
|
||||
|
||||
PERFORM create_notification(
|
||||
_new_owner_id,
|
||||
_team_id,
|
||||
NULL,
|
||||
NULL,
|
||||
CONCAT('You are now the owner of team <b>', (SELECT name FROM teams WHERE id = _team_id), '</b>')
|
||||
);
|
||||
|
||||
RETURN json_build_object(
|
||||
'success', TRUE,
|
||||
'old_owner_id', _old_owner_id,
|
||||
'new_owner_id', _new_owner_id,
|
||||
'team_id', _team_id,
|
||||
'old_org_id', _old_org_id,
|
||||
'new_org_id', COALESCE(_new_owner_org_id, _old_org_id),
|
||||
'old_role_id', _old_owner_role_id,
|
||||
'new_role_id', _new_owner_role_id,
|
||||
'has_valid_license', _has_valid_license,
|
||||
'has_active_coupon', _has_active_coupon,
|
||||
'other_teams_count', _other_teams_count,
|
||||
'org_ownership_transferred', _new_owner_org_id IS NULL,
|
||||
'team_moved_to_new_org', _new_owner_org_id IS NOT NULL
|
||||
);
|
||||
END;
|
||||
$$;
|
||||
|
||||
2442
worklenz-backend/package-lock.json
generated
2442
worklenz-backend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -11,16 +11,30 @@
|
||||
"repository": "GITHUB_REPO_HERE",
|
||||
"author": "worklenz.com",
|
||||
"scripts": {
|
||||
"start": "node ./build/bin/www",
|
||||
"tcs": "grunt build:tsc",
|
||||
"build": "grunt build",
|
||||
"watch": "grunt watch",
|
||||
"dev": "grunt dev",
|
||||
"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",
|
||||
"test": "jest",
|
||||
"start": "node build/bin/www.js",
|
||||
"dev": "npm run build:dev && npm run watch",
|
||||
"build": "npm run clean && npm run compile && npm run copy && npm run compress",
|
||||
"build:dev": "npm run clean && npm run compile:dev && npm run copy",
|
||||
"build:prod": "npm run clean && npm run compile:prod && npm run copy && npm run minify && npm run compress",
|
||||
"clean": "rimraf build",
|
||||
"compile": "tsc --build tsconfig.prod.json",
|
||||
"compile:dev": "tsc --build tsconfig.json",
|
||||
"compile:prod": "tsc --build tsconfig.prod.json",
|
||||
"copy": "npm run copy:assets && npm run copy:views && npm run copy:config && npm run copy:shared",
|
||||
"copy:assets": "npx cpx2 \"src/public/**\" build/public",
|
||||
"copy:views": "npx cpx2 \"src/views/**\" build/views",
|
||||
"copy:config": "npx cpx2 \".env\" build && npx cpx2 \"package.json\" build",
|
||||
"copy:shared": "npx cpx2 \"src/shared/postgresql-error-codes.json\" build/shared && npx cpx2 \"src/shared/sample-data.json\" build/shared && npx cpx2 \"src/shared/templates/**\" build/shared/templates",
|
||||
"watch": "concurrently \"npm run watch:ts\" \"npm run watch:assets\"",
|
||||
"watch:ts": "tsc --build tsconfig.json --watch",
|
||||
"watch:assets": "npx cpx2 \"src/{public,views}/**\" build --watch",
|
||||
"minify": "terser build/**/*.js --compress --mangle --output-dir build",
|
||||
"compress": "node scripts/compress.js",
|
||||
"swagger": "node ./cli/swagger",
|
||||
"inline-queries": "node ./cli/inline-queries",
|
||||
"sonar": "sonar-scanner -Dproject.settings=sonar-project-dev.properties",
|
||||
"tsc": "tsc",
|
||||
"test": "jest --setupFiles dotenv/config",
|
||||
"test:watch": "jest --watch --setupFiles dotenv/config"
|
||||
},
|
||||
"jestSonar": {
|
||||
@@ -45,7 +59,8 @@
|
||||
"cors": "^2.8.5",
|
||||
"cron": "^2.4.0",
|
||||
"crypto-js": "^4.1.1",
|
||||
"csurf": "^1.2.2",
|
||||
"csrf-sync": "^4.2.1",
|
||||
"csurf": "^1.11.0",
|
||||
"debug": "^4.3.4",
|
||||
"dotenv": "^16.3.1",
|
||||
"exceljs": "^4.3.0",
|
||||
@@ -125,26 +140,22 @@
|
||||
"@typescript-eslint/eslint-plugin": "^5.62.0",
|
||||
"@typescript-eslint/parser": "^5.62.0",
|
||||
"chokidar": "^3.5.3",
|
||||
"esbuild": "^0.25.4",
|
||||
"concurrently": "^9.1.2",
|
||||
"cpx2": "^8.0.0",
|
||||
"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-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",
|
||||
"highcharts": "^11.1.0",
|
||||
"jest": "^28.1.3",
|
||||
"jest-sonar-reporter": "^2.0.0",
|
||||
"ncp": "^2.0.0",
|
||||
"nodeman": "^1.1.2",
|
||||
"rimraf": "^6.0.1",
|
||||
"swagger-jsdoc": "^6.2.8",
|
||||
"terser": "^5.40.0",
|
||||
"ts-jest": "^28.0.8",
|
||||
"ts-node": "^10.9.1",
|
||||
"tslint": "^6.1.3",
|
||||
|
||||
53
worklenz-backend/scripts/compress.js
Normal file
53
worklenz-backend/scripts/compress.js
Normal file
@@ -0,0 +1,53 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { createGzip } = require('zlib');
|
||||
const { pipeline } = require('stream');
|
||||
|
||||
async function compressFile(inputPath, outputPath) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const gzip = createGzip();
|
||||
const source = fs.createReadStream(inputPath);
|
||||
const destination = fs.createWriteStream(outputPath);
|
||||
|
||||
pipeline(source, gzip, destination, (err) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function compressDirectory(dir) {
|
||||
const files = fs.readdirSync(dir, { withFileTypes: true });
|
||||
|
||||
for (const file of files) {
|
||||
const fullPath = path.join(dir, file.name);
|
||||
|
||||
if (file.isDirectory()) {
|
||||
await compressDirectory(fullPath);
|
||||
} else if (file.name.endsWith('.js') || file.name.endsWith('.css')) {
|
||||
const gzPath = fullPath + '.gz';
|
||||
await compressFile(fullPath, gzPath);
|
||||
console.log(`Compressed: ${fullPath} -> ${gzPath}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
try {
|
||||
const buildDir = path.join(__dirname, '../build');
|
||||
if (fs.existsSync(buildDir)) {
|
||||
await compressDirectory(buildDir);
|
||||
console.log('Compression complete!');
|
||||
} else {
|
||||
console.log('Build directory not found. Run build first.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Compression failed:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -6,7 +6,7 @@ import logger from "morgan";
|
||||
import helmet from "helmet";
|
||||
import compression from "compression";
|
||||
import passport from "passport";
|
||||
import csurf from "csurf";
|
||||
import { csrfSync } from "csrf-sync";
|
||||
import rateLimit from "express-rate-limit";
|
||||
import cors from "cors";
|
||||
import flash from "connect-flash";
|
||||
@@ -112,17 +112,13 @@ function isLoggedIn(req: Request, _res: Response, next: NextFunction) {
|
||||
return req.user ? next() : next(createError(401));
|
||||
}
|
||||
|
||||
// CSRF configuration
|
||||
const csrfProtection = csurf({
|
||||
cookie: {
|
||||
key: "XSRF-TOKEN",
|
||||
path: "/",
|
||||
httpOnly: false,
|
||||
secure: isProduction(), // Only secure in production
|
||||
sameSite: isProduction() ? "none" : "lax", // Different settings for dev vs prod
|
||||
domain: isProduction() ? ".worklenz.com" : undefined // Only set domain in production
|
||||
},
|
||||
ignoreMethods: ["HEAD", "OPTIONS"]
|
||||
// CSRF configuration using csrf-sync for session-based authentication
|
||||
const {
|
||||
invalidCsrfTokenError,
|
||||
generateToken,
|
||||
csrfSynchronisedProtection,
|
||||
} = csrfSync({
|
||||
getTokenFromRequest: (req: Request) => req.headers["x-csrf-token"] as string || (req.body && req.body["_csrf"])
|
||||
});
|
||||
|
||||
// Apply CSRF selectively (exclude webhooks and public routes)
|
||||
@@ -135,38 +131,25 @@ app.use((req, res, next) => {
|
||||
) {
|
||||
next();
|
||||
} else {
|
||||
csrfProtection(req, res, next);
|
||||
csrfSynchronisedProtection(req, res, next);
|
||||
}
|
||||
});
|
||||
|
||||
// Set CSRF token cookie
|
||||
// Set CSRF token method on request object for compatibility
|
||||
app.use((req: Request, res: Response, next: NextFunction) => {
|
||||
if (req.csrfToken) {
|
||||
const token = req.csrfToken();
|
||||
res.cookie("XSRF-TOKEN", token, {
|
||||
httpOnly: false,
|
||||
secure: isProduction(),
|
||||
sameSite: isProduction() ? "none" : "lax",
|
||||
domain: isProduction() ? ".worklenz.com" : undefined,
|
||||
path: "/"
|
||||
});
|
||||
// Add csrfToken method to request object for compatibility
|
||||
if (!req.csrfToken && generateToken) {
|
||||
req.csrfToken = (overwrite?: boolean) => generateToken(req, overwrite);
|
||||
}
|
||||
next();
|
||||
});
|
||||
|
||||
// CSRF token refresh endpoint
|
||||
app.get("/csrf-token", (req: Request, res: Response) => {
|
||||
if (req.csrfToken) {
|
||||
const token = req.csrfToken();
|
||||
res.cookie("XSRF-TOKEN", token, {
|
||||
httpOnly: false,
|
||||
secure: isProduction(),
|
||||
sameSite: isProduction() ? "none" : "lax",
|
||||
domain: isProduction() ? ".worklenz.com" : undefined,
|
||||
path: "/"
|
||||
});
|
||||
res.status(200).json({ done: true, message: "CSRF token refreshed" });
|
||||
} else {
|
||||
try {
|
||||
const token = generateToken(req);
|
||||
res.status(200).json({ done: true, message: "CSRF token refreshed", token });
|
||||
} catch (error) {
|
||||
res.status(500).json({ done: false, message: "Failed to generate CSRF token" });
|
||||
}
|
||||
});
|
||||
@@ -219,7 +202,7 @@ if (isInternalServer()) {
|
||||
|
||||
// CSRF error handler
|
||||
app.use((err: any, req: Request, res: Response, next: NextFunction) => {
|
||||
if (err.code === "EBADCSRFTOKEN") {
|
||||
if (err === invalidCsrfTokenError) {
|
||||
return res.status(403).json({
|
||||
done: false,
|
||||
message: "Invalid CSRF token",
|
||||
|
||||
@@ -5,7 +5,7 @@ import db from "../config/db";
|
||||
import {ServerResponse} from "../models/server-response";
|
||||
import WorklenzControllerBase from "./worklenz-controller-base";
|
||||
import HandleExceptions from "../decorators/handle-exceptions";
|
||||
import {calculateMonthDays, getColor, megabytesToBytes} from "../shared/utils";
|
||||
import {calculateMonthDays, getColor, log_error, megabytesToBytes} from "../shared/utils";
|
||||
import moment from "moment";
|
||||
import {calculateStorage} from "../shared/s3";
|
||||
import {checkTeamSubscriptionStatus, getActiveTeamMemberCount, getCurrentProjectsCount, getFreePlanSettings, getOwnerIdByTeam, getTeamMemberCount, getUsedStorage} from "../shared/paddle-utils";
|
||||
@@ -232,7 +232,11 @@ export default class AdminCenterController extends WorklenzControllerBase {
|
||||
FROM team_member_info_view
|
||||
WHERE team_member_info_view.team_member_id = tm.id),
|
||||
role_id,
|
||||
r.name AS role_name
|
||||
r.name AS role_name,
|
||||
EXISTS(SELECT email
|
||||
FROM email_invitations
|
||||
WHERE team_member_id = tm.id
|
||||
AND email_invitations.team_id = tm.team_id) AS pending_invitation
|
||||
FROM team_members tm
|
||||
LEFT JOIN users u on tm.user_id = u.id
|
||||
LEFT JOIN roles r on tm.role_id = r.id
|
||||
@@ -255,22 +259,33 @@ export default class AdminCenterController extends WorklenzControllerBase {
|
||||
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]);
|
||||
try {
|
||||
// Update team name
|
||||
const updateNameQuery = `UPDATE teams SET name = $1 WHERE id = $2 RETURNING id;`;
|
||||
const nameResult = await db.query(updateNameQuery, [name, id]);
|
||||
|
||||
if (!nameResult.rows.length) {
|
||||
return res.status(404).send(new ServerResponse(false, null, "Team not found"));
|
||||
}
|
||||
|
||||
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]);
|
||||
});
|
||||
// Update team member roles if provided
|
||||
if (teamMembers?.length) {
|
||||
// Use Promise.all to handle all role updates concurrently
|
||||
await Promise.all(teamMembers.map(async (member: { role_name: string; user_id: string; }) => {
|
||||
const roleQuery = `
|
||||
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
|
||||
RETURNING id;`;
|
||||
await db.query(roleQuery, [id, member.role_name, member.user_id]);
|
||||
}));
|
||||
}
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, null, "Team updated successfully"));
|
||||
} catch (error) {
|
||||
log_error("Error updating team:", error);
|
||||
return res.status(500).send(new ServerResponse(false, null, "Failed to update team"));
|
||||
}
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, [], "Team updated successfully"));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
|
||||
@@ -35,8 +35,18 @@ export default class AuthController extends WorklenzControllerBase {
|
||||
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;
|
||||
// Determine title based on authentication status and strategy
|
||||
let title = null;
|
||||
if (req.query.strategy) {
|
||||
if (auth_error) {
|
||||
// Show failure title only when there's an actual error
|
||||
title = req.query.strategy === "login" ? "Login Failed!" : "Signup Failed!";
|
||||
} else if (req.isAuthenticated() && message) {
|
||||
// Show success title when authenticated and there's a success message
|
||||
title = req.query.strategy === "login" ? "Login Successful!" : "Signup Successful!";
|
||||
}
|
||||
// If no error and not authenticated, don't show any title (this might be a redirect without completion)
|
||||
}
|
||||
|
||||
if (req.user)
|
||||
req.user.build_v = FileConstants.getRelease();
|
||||
|
||||
@@ -322,7 +322,7 @@ export default class ProjectInsightsController extends WorklenzControllerBase {
|
||||
(SELECT get_task_assignees(tasks.id)) AS assignees
|
||||
FROM tasks
|
||||
JOIN work_log ON work_log.task_id = tasks.id
|
||||
WHERE project_id = $1
|
||||
WHERE project_id = $1 AND total_minutes <> 0 AND (total_minutes * 60) <> work_log.total_time_spent
|
||||
AND CASE
|
||||
WHEN ($2 IS TRUE) THEN project_id IS NOT NULL
|
||||
ELSE archived IS FALSE END
|
||||
|
||||
@@ -408,6 +408,9 @@ export default class ProjectsController extends WorklenzControllerBase {
|
||||
sps.color_code AS status_color,
|
||||
sps.icon AS status_icon,
|
||||
(SELECT name FROM clients WHERE id = projects.client_id) AS client_name,
|
||||
projects.use_manual_progress,
|
||||
projects.use_weighted_progress,
|
||||
projects.use_time_progress,
|
||||
|
||||
(SELECT COALESCE(ROW_TO_JSON(pm), '{}'::JSON)
|
||||
FROM (SELECT team_member_id AS id,
|
||||
|
||||
@@ -408,6 +408,65 @@ export default class ReportingAllocationController extends ReportingControllerBa
|
||||
|
||||
const { duration, date_range } = req.body;
|
||||
|
||||
// Calculate the date range (start and end)
|
||||
let startDate: moment.Moment;
|
||||
let endDate: moment.Moment;
|
||||
if (date_range && date_range.length === 2) {
|
||||
startDate = moment(date_range[0]);
|
||||
endDate = moment(date_range[1]);
|
||||
} else if (duration === DATE_RANGES.ALL_TIME) {
|
||||
// Fetch the earliest start_date (or created_at if null) from selected projects
|
||||
const minDateQuery = `SELECT MIN(COALESCE(start_date, created_at)) as min_date FROM projects WHERE id IN (${projectIds})`;
|
||||
const minDateResult = await db.query(minDateQuery, []);
|
||||
const minDate = minDateResult.rows[0]?.min_date;
|
||||
startDate = minDate ? moment(minDate) : moment('2000-01-01');
|
||||
endDate = moment();
|
||||
} else {
|
||||
switch (duration) {
|
||||
case DATE_RANGES.YESTERDAY:
|
||||
startDate = moment().subtract(1, "day");
|
||||
endDate = moment().subtract(1, "day");
|
||||
break;
|
||||
case DATE_RANGES.LAST_WEEK:
|
||||
startDate = moment().subtract(1, "week").startOf("isoWeek");
|
||||
endDate = moment().subtract(1, "week").endOf("isoWeek");
|
||||
break;
|
||||
case DATE_RANGES.LAST_MONTH:
|
||||
startDate = moment().subtract(1, "month").startOf("month");
|
||||
endDate = moment().subtract(1, "month").endOf("month");
|
||||
break;
|
||||
case DATE_RANGES.LAST_QUARTER:
|
||||
startDate = moment().subtract(3, "months").startOf("quarter");
|
||||
endDate = moment().subtract(1, "quarter").endOf("quarter");
|
||||
break;
|
||||
default:
|
||||
startDate = moment().startOf("day");
|
||||
endDate = moment().endOf("day");
|
||||
}
|
||||
}
|
||||
|
||||
// Count only weekdays (Mon-Fri) in the period
|
||||
let workingDays = 0;
|
||||
let current = startDate.clone();
|
||||
while (current.isSameOrBefore(endDate, 'day')) {
|
||||
const day = current.isoWeekday();
|
||||
if (day >= 1 && day <= 5) workingDays++;
|
||||
current.add(1, 'day');
|
||||
}
|
||||
|
||||
// Get hours_per_day for all selected projects
|
||||
const projectHoursQuery = `SELECT id, hours_per_day FROM projects WHERE id IN (${projectIds})`;
|
||||
const projectHoursResult = await db.query(projectHoursQuery, []);
|
||||
const projectHoursMap: Record<string, number> = {};
|
||||
for (const row of projectHoursResult.rows) {
|
||||
projectHoursMap[row.id] = row.hours_per_day || 8;
|
||||
}
|
||||
// Sum total working hours for all selected projects
|
||||
let totalWorkingHours = 0;
|
||||
for (const pid of Object.keys(projectHoursMap)) {
|
||||
totalWorkingHours += workingDays * projectHoursMap[pid];
|
||||
}
|
||||
|
||||
const durationClause = this.getDateRangeClause(duration || DATE_RANGES.LAST_WEEK, date_range);
|
||||
const archivedClause = archived
|
||||
? ""
|
||||
@@ -430,6 +489,12 @@ export default class ReportingAllocationController extends ReportingControllerBa
|
||||
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);
|
||||
member.total_working_hours = totalWorkingHours;
|
||||
member.utilization_percent = (totalWorkingHours > 0 && member.logged_time) ? ((parseFloat(member.logged_time) / (totalWorkingHours * 3600)) * 100).toFixed(2) : '0.00';
|
||||
member.utilized_hours = member.logged_time ? (parseFloat(member.logged_time) / 3600).toFixed(2) : '0.00';
|
||||
// Over/under utilized hours: utilized_hours - total_working_hours
|
||||
const overUnder = member.utilized_hours && member.total_working_hours ? (parseFloat(member.utilized_hours) - member.total_working_hours) : 0;
|
||||
member.over_under_utilized_hours = overUnder.toFixed(2);
|
||||
}
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, result.rows));
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import WorklenzControllerBase from "./worklenz-controller-base";
|
||||
import {getColor} from "../shared/utils";
|
||||
import {PriorityColorCodes, TASK_PRIORITY_COLOR_ALPHA, TASK_STATUS_COLOR_ALPHA} from "../shared/constants";
|
||||
import { getColor } from "../shared/utils";
|
||||
import { PriorityColorCodes, TASK_PRIORITY_COLOR_ALPHA, TASK_STATUS_COLOR_ALPHA } from "../shared/constants";
|
||||
import moment from "moment/moment";
|
||||
|
||||
export const GroupBy = {
|
||||
@@ -32,10 +32,41 @@ export default class TasksControllerBase extends WorklenzControllerBase {
|
||||
}
|
||||
|
||||
public static updateTaskViewModel(task: any) {
|
||||
task.progress = ~~(task.total_minutes_spent / task.total_minutes * 100);
|
||||
// For parent tasks (with subtasks), always use calculated progress from subtasks
|
||||
if (task.sub_tasks_count > 0) {
|
||||
// Ensure progress matches complete_ratio for consistency
|
||||
task.progress = task.complete_ratio || 0;
|
||||
|
||||
// Important: Parent tasks should not have manual progress
|
||||
// If they somehow do, reset it
|
||||
if (task.manual_progress) {
|
||||
task.manual_progress = false;
|
||||
task.progress_value = null;
|
||||
}
|
||||
}
|
||||
// For tasks without subtasks, respect manual progress if set
|
||||
else if (task.manual_progress === true && task.progress_value !== null && task.progress_value !== undefined) {
|
||||
// For manually set progress, use that value directly
|
||||
task.progress = parseInt(task.progress_value);
|
||||
task.complete_ratio = parseInt(task.progress_value);
|
||||
}
|
||||
// For tasks with no subtasks and no manual progress, calculate based on time
|
||||
else {
|
||||
task.progress = task.total_minutes_spent && task.total_minutes
|
||||
? ~~(task.total_minutes_spent / task.total_minutes * 100)
|
||||
: 0;
|
||||
|
||||
// Set complete_ratio to match progress
|
||||
task.complete_ratio = task.progress;
|
||||
}
|
||||
|
||||
// Ensure numeric values
|
||||
task.progress = parseInt(task.progress) || 0;
|
||||
task.complete_ratio = parseInt(task.complete_ratio) || 0;
|
||||
|
||||
task.overdue = task.total_minutes < task.total_minutes_spent;
|
||||
|
||||
task.time_spent = {hours: ~~(task.total_minutes_spent / 60), minutes: task.total_minutes_spent % 60};
|
||||
task.time_spent = { hours: ~~(task.total_minutes_spent / 60), minutes: task.total_minutes_spent % 60 };
|
||||
|
||||
task.comments_count = Number(task.comments_count) ? +task.comments_count : 0;
|
||||
task.attachments_count = Number(task.attachments_count) ? +task.attachments_count : 0;
|
||||
@@ -73,9 +104,9 @@ export default class TasksControllerBase extends WorklenzControllerBase {
|
||||
if (task.timer_start_time)
|
||||
task.timer_start_time = moment(task.timer_start_time).valueOf();
|
||||
|
||||
// Set completed_count and total_tasks_count regardless of progress calculation method
|
||||
const totalCompleted = (+task.completed_sub_tasks + +task.parent_task_completed) || 0;
|
||||
const totalTasks = +task.sub_tasks_count || 0; // if needed add +1 for parent
|
||||
task.complete_ratio = TasksControllerBase.calculateTaskCompleteRatio(totalCompleted, totalTasks);
|
||||
const totalTasks = +task.sub_tasks_count || 0;
|
||||
task.completed_count = totalCompleted;
|
||||
task.total_tasks_count = totalTasks;
|
||||
|
||||
|
||||
@@ -97,9 +97,13 @@ export default class TasksControllerV2 extends TasksControllerBase {
|
||||
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;
|
||||
if (data && data.info && data.info.ratio !== undefined) {
|
||||
data.info.ratio = +((data.info.ratio || 0).toFixed());
|
||||
return data.info;
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
log_error(`Error in getTaskCompleteRatio: ${error}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -192,6 +196,13 @@ export default class TasksControllerV2 extends TasksControllerBase {
|
||||
t.archived,
|
||||
t.description,
|
||||
t.sort_order,
|
||||
t.progress_value,
|
||||
t.manual_progress,
|
||||
t.weight,
|
||||
(SELECT use_manual_progress FROM projects WHERE id = t.project_id) AS project_use_manual_progress,
|
||||
(SELECT use_weighted_progress FROM projects WHERE id = t.project_id) AS project_use_weighted_progress,
|
||||
(SELECT use_time_progress FROM projects WHERE id = t.project_id) AS project_use_time_progress,
|
||||
(SELECT get_task_complete_ratio(t.id)->>'ratio') AS complete_ratio,
|
||||
|
||||
(SELECT phase_id FROM task_phase WHERE task_id = t.id) AS phase_id,
|
||||
(SELECT name
|
||||
@@ -315,6 +326,11 @@ export default class TasksControllerV2 extends TasksControllerBase {
|
||||
|
||||
@HandleExceptions()
|
||||
public static async getList(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
// Before doing anything else, refresh task progress values for this project
|
||||
if (req.params.id) {
|
||||
await this.refreshProjectTaskProgressValues(req.params.id);
|
||||
}
|
||||
|
||||
const isSubTasks = !!req.query.parent_task;
|
||||
const groupBy = (req.query.group || GroupBy.STATUS) as string;
|
||||
|
||||
@@ -334,7 +350,7 @@ export default class TasksControllerV2 extends TasksControllerBase {
|
||||
return g;
|
||||
}, {});
|
||||
|
||||
this.updateMapByGroup(tasks, groupBy, map);
|
||||
await this.updateMapByGroup(tasks, groupBy, map);
|
||||
|
||||
const updatedGroups = Object.keys(map).map(key => {
|
||||
const group = map[key];
|
||||
@@ -353,12 +369,28 @@ export default class TasksControllerV2 extends TasksControllerBase {
|
||||
return res.status(200).send(new ServerResponse(true, updatedGroups));
|
||||
}
|
||||
|
||||
public static updateMapByGroup(tasks: any[], groupBy: string, map: { [p: string]: ITaskGroup }) {
|
||||
public static async updateMapByGroup(tasks: any[], groupBy: string, map: { [p: string]: ITaskGroup }) {
|
||||
let index = 0;
|
||||
const unmapped = [];
|
||||
|
||||
// First, ensure we have the latest progress values for all tasks
|
||||
for (const task of tasks) {
|
||||
// For any task with subtasks, ensure we have the latest progress values
|
||||
if (task.sub_tasks_count > 0) {
|
||||
const info = await this.getTaskCompleteRatio(task.id);
|
||||
if (info) {
|
||||
task.complete_ratio = info.ratio;
|
||||
task.progress_value = info.ratio; // Ensure progress_value reflects the calculated ratio
|
||||
console.log(`Updated task ${task.name} (${task.id}): complete_ratio=${task.complete_ratio}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Now group the tasks with their updated progress values
|
||||
for (const task of tasks) {
|
||||
task.index = index++;
|
||||
TasksControllerV2.updateTaskViewModel(task);
|
||||
|
||||
if (groupBy === GroupBy.STATUS) {
|
||||
map[task.status]?.tasks.push(task);
|
||||
} else if (groupBy === GroupBy.PRIORITY) {
|
||||
@@ -394,8 +426,13 @@ export default class TasksControllerV2 extends TasksControllerBase {
|
||||
|
||||
@HandleExceptions()
|
||||
public static async getTasksOnly(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
// Before doing anything else, refresh task progress values for this project
|
||||
if (req.params.id) {
|
||||
await this.refreshProjectTaskProgressValues(req.params.id);
|
||||
}
|
||||
|
||||
const isSubTasks = !!req.query.parent_task;
|
||||
|
||||
|
||||
// Add customColumns flag to query params
|
||||
req.query.customColumns = "true";
|
||||
|
||||
@@ -410,7 +447,24 @@ export default class TasksControllerV2 extends TasksControllerBase {
|
||||
[data] = result.rows;
|
||||
} else { // else we return a flat list of tasks
|
||||
data = [...result.rows];
|
||||
|
||||
for (const task of data) {
|
||||
// For tasks with subtasks, get the complete ratio from the database function
|
||||
if (task.sub_tasks_count > 0) {
|
||||
try {
|
||||
const result = await db.query("SELECT get_task_complete_ratio($1) AS info;", [task.id]);
|
||||
const [ratioData] = result.rows;
|
||||
if (ratioData && ratioData.info) {
|
||||
task.complete_ratio = +(ratioData.info.ratio || 0).toFixed();
|
||||
task.completed_count = ratioData.info.total_completed;
|
||||
task.total_tasks_count = ratioData.info.total_tasks;
|
||||
console.log(`Updated task ${task.id} (${task.name}) from DB: complete_ratio=${task.complete_ratio}`);
|
||||
}
|
||||
} catch (error) {
|
||||
// Proceed with default calculation if database call fails
|
||||
}
|
||||
}
|
||||
|
||||
TasksControllerV2.updateTaskViewModel(task);
|
||||
}
|
||||
}
|
||||
@@ -443,6 +497,53 @@ export default class TasksControllerV2 extends TasksControllerBase {
|
||||
return res.status(200).send(new ServerResponse(true, task));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async resetParentTaskManualProgress(parentTaskId: string): Promise<void> {
|
||||
try {
|
||||
// Check if this task has subtasks
|
||||
const subTasksResult = await db.query(
|
||||
"SELECT COUNT(*) as subtask_count FROM tasks WHERE parent_task_id = $1 AND archived IS FALSE",
|
||||
[parentTaskId]
|
||||
);
|
||||
|
||||
const subtaskCount = parseInt(subTasksResult.rows[0]?.subtask_count || "0");
|
||||
|
||||
// If it has subtasks, reset the manual_progress flag to false
|
||||
if (subtaskCount > 0) {
|
||||
await db.query(
|
||||
"UPDATE tasks SET manual_progress = false WHERE id = $1",
|
||||
[parentTaskId]
|
||||
);
|
||||
console.log(`Reset manual progress for parent task ${parentTaskId} with ${subtaskCount} subtasks`);
|
||||
|
||||
// Get the project settings to determine which calculation method to use
|
||||
const projectResult = await db.query(
|
||||
"SELECT project_id FROM tasks WHERE id = $1",
|
||||
[parentTaskId]
|
||||
);
|
||||
|
||||
const projectId = projectResult.rows[0]?.project_id;
|
||||
|
||||
if (projectId) {
|
||||
// Recalculate the parent task's progress based on its subtasks
|
||||
const progressResult = await db.query(
|
||||
"SELECT get_task_complete_ratio($1) AS ratio",
|
||||
[parentTaskId]
|
||||
);
|
||||
|
||||
const progressRatio = progressResult.rows[0]?.ratio?.ratio || 0;
|
||||
|
||||
// Emit the updated progress value to all clients
|
||||
// Note: We don't have socket context here, so we can't directly emit
|
||||
// This will be picked up on the next client refresh
|
||||
console.log(`Recalculated progress for parent task ${parentTaskId}: ${progressRatio}%`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
log_error(`Error resetting parent task manual progress: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async convertToSubtask(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
|
||||
@@ -482,6 +583,11 @@ export default class TasksControllerV2 extends TasksControllerBase {
|
||||
? [req.body.id, req.body.to_group_id]
|
||||
: [req.body.id, req.body.project_id, req.body.parent_task_id, req.body.to_group_id];
|
||||
await db.query(q, params);
|
||||
|
||||
// Reset the parent task's manual progress when converting a task to a subtask
|
||||
if (req.body.parent_task_id) {
|
||||
await this.resetParentTaskManualProgress(req.body.parent_task_id);
|
||||
}
|
||||
|
||||
const result = await db.query("SELECT get_single_task($1) AS task;", [req.body.id]);
|
||||
const [data] = result.rows;
|
||||
@@ -724,4 +830,126 @@ export default class TasksControllerV2 extends TasksControllerBase {
|
||||
value
|
||||
}));
|
||||
}
|
||||
|
||||
public static async refreshProjectTaskProgressValues(projectId: string): Promise<void> {
|
||||
try {
|
||||
// Run the recalculate_all_task_progress function only for tasks in this project
|
||||
const query = `
|
||||
DO $$
|
||||
BEGIN
|
||||
-- First, reset manual_progress flag for all tasks that have subtasks within this project
|
||||
UPDATE tasks AS t
|
||||
SET manual_progress = FALSE
|
||||
WHERE project_id = '${projectId}'
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM tasks
|
||||
WHERE parent_task_id = t.id
|
||||
AND archived IS FALSE
|
||||
);
|
||||
|
||||
-- Start recalculation from leaf tasks (no subtasks) and propagate upward
|
||||
-- This ensures calculations are done in the right order
|
||||
WITH RECURSIVE task_hierarchy AS (
|
||||
-- Base case: Start with all leaf tasks (no subtasks) in this project
|
||||
SELECT
|
||||
id,
|
||||
parent_task_id,
|
||||
0 AS level
|
||||
FROM tasks
|
||||
WHERE project_id = '${projectId}'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM tasks AS sub
|
||||
WHERE sub.parent_task_id = tasks.id
|
||||
AND sub.archived IS FALSE
|
||||
)
|
||||
AND archived IS FALSE
|
||||
|
||||
UNION ALL
|
||||
|
||||
-- Recursive case: Move up to parent tasks, but only after processing all their children
|
||||
SELECT
|
||||
t.id,
|
||||
t.parent_task_id,
|
||||
th.level + 1
|
||||
FROM tasks t
|
||||
JOIN task_hierarchy th ON t.id = th.parent_task_id
|
||||
WHERE t.archived IS FALSE
|
||||
)
|
||||
-- Sort by level to ensure we calculate in the right order (leaves first, then parents)
|
||||
UPDATE tasks
|
||||
SET progress_value = (SELECT (get_task_complete_ratio(tasks.id)->>'ratio')::FLOAT)
|
||||
FROM (
|
||||
SELECT id, level
|
||||
FROM task_hierarchy
|
||||
ORDER BY level
|
||||
) AS ordered_tasks
|
||||
WHERE tasks.id = ordered_tasks.id
|
||||
AND tasks.project_id = '${projectId}'
|
||||
AND (manual_progress IS FALSE OR manual_progress IS NULL);
|
||||
END $$;
|
||||
`;
|
||||
|
||||
await db.query(query);
|
||||
console.log(`Finished refreshing progress values for project ${projectId}`);
|
||||
} catch (error) {
|
||||
log_error("Error refreshing project task progress values", error);
|
||||
}
|
||||
}
|
||||
|
||||
public static async updateTaskProgress(taskId: string): Promise<void> {
|
||||
try {
|
||||
// Calculate the task's progress using get_task_complete_ratio
|
||||
const result = await db.query("SELECT get_task_complete_ratio($1) AS info;", [taskId]);
|
||||
const [data] = result.rows;
|
||||
|
||||
if (data && data.info && data.info.ratio !== undefined) {
|
||||
const progressValue = +((data.info.ratio || 0).toFixed());
|
||||
|
||||
// Update the task's progress_value in the database
|
||||
await db.query(
|
||||
"UPDATE tasks SET progress_value = $1 WHERE id = $2",
|
||||
[progressValue, taskId]
|
||||
);
|
||||
|
||||
console.log(`Updated progress for task ${taskId} to ${progressValue}%`);
|
||||
|
||||
// If this task has a parent, update the parent's progress as well
|
||||
const parentResult = await db.query(
|
||||
"SELECT parent_task_id FROM tasks WHERE id = $1",
|
||||
[taskId]
|
||||
);
|
||||
|
||||
if (parentResult.rows.length > 0 && parentResult.rows[0].parent_task_id) {
|
||||
await this.updateTaskProgress(parentResult.rows[0].parent_task_id);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
log_error(`Error updating task progress: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Add this method to update progress when a task's weight is changed
|
||||
public static async updateTaskWeight(taskId: string, weight: number): Promise<void> {
|
||||
try {
|
||||
// Update the task's weight
|
||||
await db.query(
|
||||
"UPDATE tasks SET weight = $1 WHERE id = $2",
|
||||
[weight, taskId]
|
||||
);
|
||||
|
||||
// Get the parent task ID
|
||||
const parentResult = await db.query(
|
||||
"SELECT parent_task_id FROM tasks WHERE id = $1",
|
||||
[taskId]
|
||||
);
|
||||
|
||||
// If this task has a parent, update the parent's progress
|
||||
if (parentResult.rows.length > 0 && parentResult.rows[0].parent_task_id) {
|
||||
await this.updateTaskProgress(parentResult.rows[0].parent_task_id);
|
||||
}
|
||||
} catch (error) {
|
||||
log_error(`Error updating task weight: ${error}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import {startDailyDigestJob} from "./daily-digest-job";
|
||||
import {startNotificationsJob} from "./notifications-job";
|
||||
import {startProjectDigestJob} from "./project-digest-job";
|
||||
import { startRecurringTasksJob } from "./recurring-tasks";
|
||||
import {startRecurringTasksJob} from "./recurring-tasks";
|
||||
|
||||
export function startCronJobs() {
|
||||
startNotificationsJob();
|
||||
startDailyDigestJob();
|
||||
startProjectDigestJob();
|
||||
// startRecurringTasksJob();
|
||||
if (process.env.ENABLE_RECURRING_JOBS === "true") startRecurringTasksJob();
|
||||
}
|
||||
|
||||
@@ -7,12 +7,90 @@ import TasksController from "../controllers/tasks-controller";
|
||||
|
||||
// At 11:00+00 (4.30pm+530) on every day-of-month if it's on every day-of-week from Monday through Friday.
|
||||
// const TIME = "0 11 */1 * 1-5";
|
||||
const TIME = "*/2 * * * *";
|
||||
const TIME = process.env.RECURRING_JOBS_INTERVAL || "0 11 */1 * 1-5";
|
||||
const TIME_FORMAT = "YYYY-MM-DD";
|
||||
// const TIME = "0 0 * * *"; // Runs at midnight every day
|
||||
|
||||
const log = (value: any) => console.log("recurring-task-cron-job:", value);
|
||||
|
||||
// Define future limits for different schedule types
|
||||
// More conservative limits to prevent task list clutter
|
||||
const FUTURE_LIMITS = {
|
||||
daily: moment.duration(3, "days"),
|
||||
weekly: moment.duration(1, "week"),
|
||||
monthly: moment.duration(1, "month"),
|
||||
every_x_days: (interval: number) => moment.duration(interval, "days"),
|
||||
every_x_weeks: (interval: number) => moment.duration(interval, "weeks"),
|
||||
every_x_months: (interval: number) => moment.duration(interval, "months")
|
||||
};
|
||||
|
||||
// Helper function to get the future limit based on schedule type
|
||||
function getFutureLimit(scheduleType: string, interval?: number): moment.Duration {
|
||||
switch (scheduleType) {
|
||||
case "daily":
|
||||
return FUTURE_LIMITS.daily;
|
||||
case "weekly":
|
||||
return FUTURE_LIMITS.weekly;
|
||||
case "monthly":
|
||||
return FUTURE_LIMITS.monthly;
|
||||
case "every_x_days":
|
||||
return FUTURE_LIMITS.every_x_days(interval || 1);
|
||||
case "every_x_weeks":
|
||||
return FUTURE_LIMITS.every_x_weeks(interval || 1);
|
||||
case "every_x_months":
|
||||
return FUTURE_LIMITS.every_x_months(interval || 1);
|
||||
default:
|
||||
return moment.duration(3, "days"); // Default to 3 days
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to batch create tasks
|
||||
async function createBatchTasks(template: ITaskTemplate & IRecurringSchedule, endDates: moment.Moment[]) {
|
||||
const createdTasks = [];
|
||||
|
||||
for (const nextEndDate of endDates) {
|
||||
const existingTaskQuery = `
|
||||
SELECT id FROM tasks
|
||||
WHERE schedule_id = $1 AND end_date::DATE = $2::DATE;
|
||||
`;
|
||||
const existingTaskResult = await db.query(existingTaskQuery, [template.schedule_id, nextEndDate.format(TIME_FORMAT)]);
|
||||
|
||||
if (existingTaskResult.rows.length === 0) {
|
||||
const createTaskQuery = `SELECT create_quick_task($1::json) as task;`;
|
||||
const taskData = {
|
||||
name: template.name,
|
||||
priority_id: template.priority_id,
|
||||
project_id: template.project_id,
|
||||
reporter_id: template.reporter_id,
|
||||
status_id: template.status_id || null,
|
||||
end_date: nextEndDate.format(TIME_FORMAT),
|
||||
schedule_id: template.schedule_id
|
||||
};
|
||||
const createTaskResult = await db.query(createTaskQuery, [JSON.stringify(taskData)]);
|
||||
const createdTask = createTaskResult.rows[0].task;
|
||||
|
||||
if (createdTask) {
|
||||
createdTasks.push(createdTask);
|
||||
|
||||
for (const assignee of template.assignees) {
|
||||
await TasksController.createTaskBulkAssignees(assignee.team_member_id, template.project_id, createdTask.id, assignee.assigned_by);
|
||||
}
|
||||
|
||||
for (const label of template.labels) {
|
||||
const q = `SELECT add_or_remove_task_label($1, $2) AS labels;`;
|
||||
await db.query(q, [createdTask.id, label.label_id]);
|
||||
}
|
||||
|
||||
console.log(`Created task for template ${template.name} with end date ${nextEndDate.format(TIME_FORMAT)}`);
|
||||
}
|
||||
} else {
|
||||
console.log(`Skipped creating task for template ${template.name} with end date ${nextEndDate.format(TIME_FORMAT)} - task already exists`);
|
||||
}
|
||||
}
|
||||
|
||||
return createdTasks;
|
||||
}
|
||||
|
||||
async function onRecurringTaskJobTick() {
|
||||
try {
|
||||
log("(cron) Recurring tasks job started.");
|
||||
@@ -33,65 +111,44 @@ async function onRecurringTaskJobTick() {
|
||||
? moment(template.last_task_end_date)
|
||||
: moment(template.created_at);
|
||||
|
||||
const futureLimit = moment(template.last_checked_at || template.created_at).add(1, "week");
|
||||
// Calculate future limit based on schedule type
|
||||
const futureLimit = moment(template.last_checked_at || template.created_at)
|
||||
.add(getFutureLimit(
|
||||
template.schedule_type,
|
||||
template.interval_days || template.interval_weeks || template.interval_months || 1
|
||||
));
|
||||
|
||||
let nextEndDate = calculateNextEndDate(template, lastTaskEndDate);
|
||||
const endDatesToCreate: moment.Moment[] = [];
|
||||
|
||||
// Find the next future occurrence
|
||||
while (nextEndDate.isSameOrBefore(now)) {
|
||||
// Find all future occurrences within the limit
|
||||
while (nextEndDate.isSameOrBefore(futureLimit)) {
|
||||
if (nextEndDate.isAfter(now)) {
|
||||
endDatesToCreate.push(moment(nextEndDate));
|
||||
}
|
||||
nextEndDate = calculateNextEndDate(template, nextEndDate);
|
||||
}
|
||||
|
||||
// Only create a task if it's within the future limit
|
||||
if (nextEndDate.isSameOrBefore(futureLimit)) {
|
||||
const existingTaskQuery = `
|
||||
SELECT id FROM tasks
|
||||
WHERE schedule_id = $1 AND end_date::DATE = $2::DATE;
|
||||
// Batch create tasks for all future dates
|
||||
if (endDatesToCreate.length > 0) {
|
||||
const createdTasks = await createBatchTasks(template, endDatesToCreate);
|
||||
createdTaskCount += createdTasks.length;
|
||||
|
||||
// Update the last_checked_at in the schedule
|
||||
const updateScheduleQuery = `
|
||||
UPDATE task_recurring_schedules
|
||||
SET last_checked_at = $1::DATE,
|
||||
last_created_task_end_date = $2
|
||||
WHERE id = $3;
|
||||
`;
|
||||
const existingTaskResult = await db.query(existingTaskQuery, [template.schedule_id, nextEndDate.format(TIME_FORMAT)]);
|
||||
|
||||
if (existingTaskResult.rows.length === 0) {
|
||||
const createTaskQuery = `SELECT create_quick_task($1::json) as task;`;
|
||||
const taskData = {
|
||||
name: template.name,
|
||||
priority_id: template.priority_id,
|
||||
project_id: template.project_id,
|
||||
reporter_id: template.reporter_id,
|
||||
status_id: template.status_id || null,
|
||||
end_date: nextEndDate.format(TIME_FORMAT),
|
||||
schedule_id: template.schedule_id
|
||||
};
|
||||
const createTaskResult = await db.query(createTaskQuery, [JSON.stringify(taskData)]);
|
||||
const createdTask = createTaskResult.rows[0].task;
|
||||
|
||||
if (createdTask) {
|
||||
createdTaskCount++;
|
||||
|
||||
for (const assignee of template.assignees) {
|
||||
await TasksController.createTaskBulkAssignees(assignee.team_member_id, template.project_id, createdTask.id, assignee.assigned_by);
|
||||
}
|
||||
|
||||
for (const label of template.labels) {
|
||||
const q = `SELECT add_or_remove_task_label($1, $2) AS labels;`;
|
||||
await db.query(q, [createdTask.id, label.label_id]);
|
||||
}
|
||||
|
||||
console.log(`Created task for template ${template.name} with end date ${nextEndDate.format(TIME_FORMAT)}`);
|
||||
}
|
||||
} else {
|
||||
console.log(`Skipped creating task for template ${template.name} with end date ${nextEndDate.format(TIME_FORMAT)} - task already exists`);
|
||||
}
|
||||
await db.query(updateScheduleQuery, [
|
||||
moment().format(TIME_FORMAT),
|
||||
endDatesToCreate[endDatesToCreate.length - 1].format(TIME_FORMAT),
|
||||
template.schedule_id
|
||||
]);
|
||||
} else {
|
||||
console.log(`No task created for template ${template.name} - next occurrence is beyond the future limit`);
|
||||
console.log(`No tasks created for template ${template.name} - next occurrence is beyond the future limit`);
|
||||
}
|
||||
|
||||
// Update the last_checked_at in the schedule
|
||||
const updateScheduleQuery = `
|
||||
UPDATE task_recurring_schedules
|
||||
SET last_checked_at = $1::DATE, last_created_task_end_date = $2
|
||||
WHERE id = $3;
|
||||
`;
|
||||
await db.query(updateScheduleQuery, [moment(template.last_checked_at || template.created_at).add(1, "day").format(TIME_FORMAT), nextEndDate.format(TIME_FORMAT), template.schedule_id]);
|
||||
}
|
||||
|
||||
log(`(cron) Recurring tasks job ended with ${createdTaskCount} new tasks created.`);
|
||||
|
||||
@@ -6,11 +6,11 @@ import { isProduction } from "../shared/utils";
|
||||
const pgSession = require("connect-pg-simple")(session);
|
||||
|
||||
export default session({
|
||||
name: process.env.SESSION_NAME,
|
||||
name: process.env.SESSION_NAME || "worklenz.sid",
|
||||
secret: process.env.SESSION_SECRET || "development-secret-key",
|
||||
proxy: false,
|
||||
proxy: true,
|
||||
resave: false,
|
||||
saveUninitialized: true,
|
||||
saveUninitialized: false,
|
||||
rolling: true,
|
||||
store: new pgSession({
|
||||
pool: db.pool,
|
||||
@@ -18,10 +18,9 @@ export default session({
|
||||
}),
|
||||
cookie: {
|
||||
path: "/",
|
||||
// secure: isProduction(),
|
||||
// httpOnly: isProduction(),
|
||||
// sameSite: "none",
|
||||
// domain: isProduction() ? ".worklenz.com" : undefined,
|
||||
secure: isProduction(), // Use secure cookies in production
|
||||
httpOnly: true,
|
||||
sameSite: "lax", // Standard setting for same-origin requests
|
||||
maxAge: 30 * 24 * 60 * 60 * 1000 // 30 days
|
||||
}
|
||||
});
|
||||
@@ -3,13 +3,16 @@ import { Strategy as LocalStrategy } from "passport-local";
|
||||
import { log_error } from "../../shared/utils";
|
||||
import db from "../../config/db";
|
||||
import { Request } from "express";
|
||||
import { ERROR_KEY, SUCCESS_KEY } from "./passport-constants";
|
||||
|
||||
async function handleLogin(req: Request, email: string, password: string, done: any) {
|
||||
console.log("Login attempt for:", email);
|
||||
// Clear any existing flash messages
|
||||
(req.session as any).flash = {};
|
||||
|
||||
if (!email || !password) {
|
||||
console.log("Missing credentials");
|
||||
return done(null, false, { message: "Please enter both email and password" });
|
||||
const errorMsg = "Please enter both email and password";
|
||||
req.flash(ERROR_KEY, errorMsg);
|
||||
return done(null, false);
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -19,23 +22,27 @@ async function handleLogin(req: Request, email: string, password: string, done:
|
||||
AND google_id IS NULL
|
||||
AND is_deleted IS FALSE;`;
|
||||
const result = await db.query(q, [email]);
|
||||
console.log("User query result count:", result.rowCount);
|
||||
|
||||
const [data] = result.rows;
|
||||
|
||||
if (!data?.password) {
|
||||
console.log("No account found");
|
||||
return done(null, false, { message: "No account found with this email" });
|
||||
const errorMsg = "No account found with this email";
|
||||
req.flash(ERROR_KEY, errorMsg);
|
||||
return done(null, false);
|
||||
}
|
||||
|
||||
const passwordMatch = bcrypt.compareSync(password, data.password);
|
||||
console.log("Password match:", passwordMatch);
|
||||
|
||||
if (passwordMatch && email === data.email) {
|
||||
delete data.password;
|
||||
return done(null, data, {message: "User successfully logged in"});
|
||||
const successMsg = "User successfully logged in";
|
||||
req.flash(SUCCESS_KEY, successMsg);
|
||||
return done(null, data);
|
||||
}
|
||||
return done(null, false, { message: "Incorrect email or password" });
|
||||
|
||||
const errorMsg = "Incorrect email or password";
|
||||
req.flash(ERROR_KEY, errorMsg);
|
||||
return done(null, false);
|
||||
} catch (error) {
|
||||
console.error("Login error:", error);
|
||||
log_error(error, req.body);
|
||||
|
||||
@@ -204,3 +204,29 @@ export async function logPhaseChange(activityLog: IActivityLog) {
|
||||
insertToActivityLogs(activityLog);
|
||||
}
|
||||
}
|
||||
|
||||
export async function logProgressChange(activityLog: IActivityLog) {
|
||||
const { task_id, new_value, old_value } = activityLog;
|
||||
if (!task_id || !activityLog.socket) return;
|
||||
|
||||
if (old_value !== new_value) {
|
||||
activityLog.user_id = getLoggedInUserIdFromSocket(activityLog.socket);
|
||||
activityLog.attribute_type = IActivityLogAttributeTypes.PROGRESS;
|
||||
activityLog.log_type = IActivityLogChangeType.UPDATE;
|
||||
|
||||
insertToActivityLogs(activityLog);
|
||||
}
|
||||
}
|
||||
|
||||
export async function logWeightChange(activityLog: IActivityLog) {
|
||||
const { task_id, new_value, old_value } = activityLog;
|
||||
if (!task_id || !activityLog.socket) return;
|
||||
|
||||
if (old_value !== new_value) {
|
||||
activityLog.user_id = getLoggedInUserIdFromSocket(activityLog.socket);
|
||||
activityLog.attribute_type = IActivityLogAttributeTypes.WEIGHT;
|
||||
activityLog.log_type = IActivityLogChangeType.UPDATE;
|
||||
|
||||
insertToActivityLogs(activityLog);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,8 @@ export enum IActivityLogAttributeTypes {
|
||||
COMMENT = "comment",
|
||||
ARCHIVE = "archive",
|
||||
PHASE = "phase",
|
||||
PROGRESS = "progress",
|
||||
WEIGHT = "weight",
|
||||
}
|
||||
|
||||
export enum IActivityLogChangeType {
|
||||
|
||||
@@ -117,11 +117,11 @@ export const TASK_DUE_NO_DUE_COLOR = "#a9a9a9";
|
||||
export const DEFAULT_PAGE_SIZE = 20;
|
||||
|
||||
// S3 Credentials
|
||||
export const REGION = process.env.AWS_REGION || "us-east-1";
|
||||
export const BUCKET = process.env.AWS_BUCKET || "your-bucket-name";
|
||||
export const REGION = process.env.S3_REGION || "us-east-1";
|
||||
export const BUCKET = process.env.S3_BUCKET || "your-bucket-name";
|
||||
export const S3_URL = process.env.S3_URL || "https://your-s3-url";
|
||||
export const S3_ACCESS_KEY_ID = process.env.AWS_ACCESS_KEY_ID || "";
|
||||
export const S3_SECRET_ACCESS_KEY = process.env.AWS_SECRET_ACCESS_KEY || "";
|
||||
export const S3_ACCESS_KEY_ID = process.env.S3_ACCESS_KEY_ID || "";
|
||||
export const S3_SECRET_ACCESS_KEY = process.env.S3_SECRET_ACCESS_KEY || "";
|
||||
|
||||
// Azure Blob Storage Credentials
|
||||
export const STORAGE_PROVIDER = process.env.STORAGE_PROVIDER || "s3";
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Socket } from "socket.io";
|
||||
import db from "../../config/db";
|
||||
import { log_error } from "../util";
|
||||
|
||||
// Define a type for the callback function
|
||||
type DoneStatusesCallback = (statuses: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
sort_order: number;
|
||||
color_code: string;
|
||||
}>) => void;
|
||||
|
||||
/**
|
||||
* Socket handler to get task statuses in the "done" category for a project
|
||||
* Used when prompting users to mark a task as done when progress reaches 100%
|
||||
*/
|
||||
export async function on_get_done_statuses(
|
||||
io: any,
|
||||
socket: Socket,
|
||||
projectId: string,
|
||||
callback: DoneStatusesCallback
|
||||
) {
|
||||
try {
|
||||
if (!projectId) {
|
||||
return callback([]);
|
||||
}
|
||||
|
||||
// Query to get all statuses in the "done" category for the project
|
||||
const result = await db.query(`
|
||||
SELECT ts.id, ts.name, ts.sort_order, stsc.color_code
|
||||
FROM task_statuses ts
|
||||
INNER JOIN sys_task_status_categories stsc ON ts.category_id = stsc.id
|
||||
WHERE ts.project_id = $1
|
||||
AND stsc.is_done = TRUE
|
||||
ORDER BY ts.sort_order ASC
|
||||
`, [projectId]);
|
||||
|
||||
const doneStatuses = result.rows;
|
||||
|
||||
console.log(`Found ${doneStatuses.length} "done" statuses for project ${projectId}`);
|
||||
|
||||
// Use callback to return the result
|
||||
callback(doneStatuses);
|
||||
|
||||
} catch (error) {
|
||||
log_error(`Error getting "done" statuses for project ${projectId}: ${error}`);
|
||||
callback([]);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,8 @@ import TasksControllerV2 from "../../controllers/tasks-controller-v2";
|
||||
|
||||
export async function on_get_task_progress(_io: Server, socket: Socket, taskId?: string) {
|
||||
try {
|
||||
console.log(`GET_TASK_PROGRESS requested for task: ${taskId}`);
|
||||
|
||||
const task: any = {};
|
||||
task.id = taskId;
|
||||
|
||||
@@ -13,6 +15,8 @@ export async function on_get_task_progress(_io: Server, socket: Socket, taskId?:
|
||||
task.complete_ratio = info.ratio;
|
||||
task.completed_count = info.total_completed;
|
||||
task.total_tasks_count = info.total_tasks;
|
||||
|
||||
console.log(`Sending task progress for task ${taskId}: complete_ratio=${task.complete_ratio}`);
|
||||
}
|
||||
|
||||
return socket.emit(SocketEvents.GET_TASK_PROGRESS.toString(), task);
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { Socket } from "socket.io";
|
||||
import db from "../../config/db";
|
||||
import { SocketEvents } from "../events";
|
||||
import { log_error } from "../util";
|
||||
|
||||
/**
|
||||
* Socket handler to retrieve the number of subtasks for a given task
|
||||
* Used to validate on the client side whether a task should show progress inputs
|
||||
*/
|
||||
export async function on_get_task_subtasks_count(io: any, socket: Socket, taskId: string) {
|
||||
try {
|
||||
if (!taskId) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the count of subtasks for this task
|
||||
const result = await db.query(
|
||||
"SELECT COUNT(*) as subtask_count FROM tasks WHERE parent_task_id = $1 AND archived IS FALSE",
|
||||
[taskId]
|
||||
);
|
||||
|
||||
const subtaskCount = parseInt(result.rows[0]?.subtask_count || "0");
|
||||
|
||||
// Emit the subtask count back to the client
|
||||
socket.emit(
|
||||
"TASK_SUBTASKS_COUNT",
|
||||
{
|
||||
task_id: taskId,
|
||||
subtask_count: subtaskCount,
|
||||
has_subtasks: subtaskCount > 0
|
||||
}
|
||||
);
|
||||
|
||||
console.log(`Emitted subtask count for task ${taskId}: ${subtaskCount}`);
|
||||
|
||||
// If there are subtasks, also get their progress information
|
||||
if (subtaskCount > 0) {
|
||||
// Get all subtasks for this parent task with their progress information
|
||||
const subtasksResult = await db.query(`
|
||||
SELECT
|
||||
t.id,
|
||||
t.progress_value,
|
||||
t.manual_progress,
|
||||
t.weight,
|
||||
CASE
|
||||
WHEN t.manual_progress = TRUE THEN t.progress_value
|
||||
ELSE COALESCE(
|
||||
(SELECT (CASE WHEN tl.total_minutes > 0 THEN
|
||||
(tl.total_minutes_spent / tl.total_minutes * 100)
|
||||
ELSE 0 END)
|
||||
FROM (
|
||||
SELECT
|
||||
t2.id,
|
||||
t2.total_minutes,
|
||||
COALESCE(SUM(twl.time_spent), 0) as total_minutes_spent
|
||||
FROM tasks t2
|
||||
LEFT JOIN task_work_log twl ON t2.id = twl.task_id
|
||||
WHERE t2.id = t.id
|
||||
GROUP BY t2.id, t2.total_minutes
|
||||
) tl
|
||||
), 0)
|
||||
END as calculated_progress
|
||||
FROM tasks t
|
||||
WHERE t.parent_task_id = $1 AND t.archived IS FALSE
|
||||
`, [taskId]);
|
||||
|
||||
// Emit progress updates for each subtask
|
||||
for (const subtask of subtasksResult.rows) {
|
||||
const progressValue = subtask.manual_progress ?
|
||||
subtask.progress_value :
|
||||
Math.floor(subtask.calculated_progress);
|
||||
|
||||
socket.emit(
|
||||
SocketEvents.TASK_PROGRESS_UPDATED.toString(),
|
||||
{
|
||||
task_id: subtask.id,
|
||||
progress_value: progressValue,
|
||||
weight: subtask.weight
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
console.log(`Emitted progress updates for ${subtasksResult.rows.length} subtasks of task ${taskId}`);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
log_error(`Error getting subtask count for task ${taskId}: ${error}`);
|
||||
}
|
||||
}
|
||||
@@ -4,10 +4,11 @@ import db from "../../config/db";
|
||||
import {NotificationsService} from "../../services/notifications/notifications.service";
|
||||
import {TASK_STATUS_COLOR_ALPHA} from "../../shared/constants";
|
||||
import {SocketEvents} from "../events";
|
||||
import {getLoggedInUserIdFromSocket, log_error, notifyProjectUpdates} from "../util";
|
||||
import {getLoggedInUserIdFromSocket, log, log_error, notifyProjectUpdates} from "../util";
|
||||
import TasksControllerV2 from "../../controllers/tasks-controller-v2";
|
||||
import {getTaskDetails, logStatusChange} from "../../services/activity-logs/activity-logs.service";
|
||||
import {getTaskDetails, logProgressChange, logStatusChange} from "../../services/activity-logs/activity-logs.service";
|
||||
import { assignMemberIfNot } from "./on-quick-assign-or-remove";
|
||||
import logger from "../../utils/logger";
|
||||
|
||||
export async function on_task_status_change(_io: Server, socket: Socket, data?: string) {
|
||||
try {
|
||||
@@ -49,6 +50,46 @@ export async function on_task_status_change(_io: Server, socket: Socket, data?:
|
||||
});
|
||||
}
|
||||
|
||||
// Check if the new status is in a "done" category
|
||||
if (changeResponse.status_category?.is_done) {
|
||||
// Get current progress value
|
||||
const progressResult = await db.query(`
|
||||
SELECT progress_value, manual_progress
|
||||
FROM tasks
|
||||
WHERE id = $1
|
||||
`, [body.task_id]);
|
||||
|
||||
const currentProgress = progressResult.rows[0]?.progress_value;
|
||||
const isManualProgress = progressResult.rows[0]?.manual_progress;
|
||||
|
||||
// Only update if not already 100%
|
||||
if (currentProgress !== 100) {
|
||||
// Update progress to 100%
|
||||
await db.query(`
|
||||
UPDATE tasks
|
||||
SET progress_value = 100, manual_progress = TRUE
|
||||
WHERE id = $1
|
||||
`, [body.task_id]);
|
||||
|
||||
log(`Task ${body.task_id} moved to done status - progress automatically set to 100%`, null);
|
||||
|
||||
// Log the progress change to activity logs
|
||||
await logProgressChange({
|
||||
task_id: body.task_id,
|
||||
old_value: currentProgress !== null ? currentProgress.toString() : "0",
|
||||
new_value: "100",
|
||||
socket
|
||||
});
|
||||
|
||||
// If this is a subtask, update parent task progress
|
||||
if (body.parent_task) {
|
||||
setTimeout(() => {
|
||||
socket.emit(SocketEvents.GET_TASK_PROGRESS.toString(), body.parent_task);
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const info = await TasksControllerV2.getTaskCompleteRatio(body.parent_task || body.task_id);
|
||||
|
||||
socket.emit(SocketEvents.TASK_STATUS_CHANGE.toString(), {
|
||||
|
||||
@@ -6,10 +6,76 @@ import { SocketEvents } from "../events";
|
||||
import { log_error, notifyProjectUpdates } from "../util";
|
||||
import { getTaskDetails, logTotalMinutes } from "../../services/activity-logs/activity-logs.service";
|
||||
|
||||
export async function on_time_estimation_change(_io: Server, socket: Socket, data?: string) {
|
||||
/**
|
||||
* Recursively updates all ancestor tasks' progress when a subtask changes
|
||||
* @param io Socket.io instance
|
||||
* @param socket Socket instance for emitting events
|
||||
* @param projectId Project ID for room broadcasting
|
||||
* @param taskId The task ID to update (starts with the parent task)
|
||||
*/
|
||||
async function updateTaskAncestors(io: any, socket: Socket, projectId: string, taskId: string | null) {
|
||||
if (!taskId) return;
|
||||
|
||||
try {
|
||||
// Get the current task's progress ratio
|
||||
const progressRatio = await db.query(
|
||||
"SELECT get_task_complete_ratio($1) as ratio",
|
||||
[taskId]
|
||||
);
|
||||
|
||||
const ratio = progressRatio?.rows[0]?.ratio?.ratio || 0;
|
||||
console.log(`Updated task ${taskId} progress after time estimation change: ${ratio}`);
|
||||
|
||||
// Check if this task needs a "done" status prompt
|
||||
let shouldPromptForDone = false;
|
||||
|
||||
if (ratio >= 100) {
|
||||
// Get the task's current status
|
||||
const taskStatusResult = await db.query(`
|
||||
SELECT ts.id, stsc.is_done
|
||||
FROM tasks t
|
||||
JOIN task_statuses ts ON t.status_id = ts.id
|
||||
JOIN sys_task_status_categories stsc ON ts.category_id = stsc.id
|
||||
WHERE t.id = $1
|
||||
`, [taskId]);
|
||||
|
||||
// If the task isn't already in a "done" category, we should prompt the user
|
||||
if (taskStatusResult.rows.length > 0 && !taskStatusResult.rows[0].is_done) {
|
||||
shouldPromptForDone = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Emit the updated progress
|
||||
socket.emit(
|
||||
SocketEvents.TASK_PROGRESS_UPDATED.toString(),
|
||||
{
|
||||
task_id: taskId,
|
||||
progress_value: ratio,
|
||||
should_prompt_for_done: shouldPromptForDone
|
||||
}
|
||||
);
|
||||
|
||||
// Find this task's parent to continue the recursive update
|
||||
const parentResult = await db.query(
|
||||
"SELECT parent_task_id FROM tasks WHERE id = $1",
|
||||
[taskId]
|
||||
);
|
||||
|
||||
const parentTaskId = parentResult.rows[0]?.parent_task_id;
|
||||
|
||||
// If there's a parent, recursively update it
|
||||
if (parentTaskId) {
|
||||
await updateTaskAncestors(io, socket, projectId, parentTaskId);
|
||||
}
|
||||
} catch (error) {
|
||||
log_error(`Error updating ancestor task ${taskId}: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function on_time_estimation_change(io: Server, socket: Socket, data?: string) {
|
||||
try {
|
||||
// (SELECT SUM(time_spent) FROM task_work_log WHERE task_id = t.id) AS total_minutes_spent,
|
||||
const q = `UPDATE tasks SET total_minutes = $2 WHERE id = $1 RETURNING total_minutes;`;
|
||||
const q = `UPDATE tasks SET total_minutes = $2 WHERE id = $1 RETURNING total_minutes, project_id, parent_task_id;`;
|
||||
const body = JSON.parse(data as string);
|
||||
|
||||
const hours = body.total_hours || 0;
|
||||
@@ -19,7 +85,10 @@ export async function on_time_estimation_change(_io: Server, socket: Socket, dat
|
||||
const task_data = await getTaskDetails(body.task_id, "total_minutes");
|
||||
|
||||
const result0 = await db.query(q, [body.task_id, totalMinutes]);
|
||||
const [data0] = result0.rows;
|
||||
const [taskData] = result0.rows;
|
||||
|
||||
const projectId = taskData.project_id;
|
||||
const parentTaskId = taskData.parent_task_id;
|
||||
|
||||
const result = await db.query("SELECT SUM(time_spent) AS total_minutes_spent FROM task_work_log WHERE task_id = $1;", [body.task_id]);
|
||||
const [dd] = result.rows;
|
||||
@@ -31,6 +100,22 @@ export async function on_time_estimation_change(_io: Server, socket: Socket, dat
|
||||
total_minutes_spent: dd.total_minutes_spent || 0
|
||||
};
|
||||
socket.emit(SocketEvents.TASK_TIME_ESTIMATION_CHANGE.toString(), TasksController.updateTaskViewModel(d));
|
||||
|
||||
// If this is a subtask in time-based mode, update parent task progress
|
||||
if (parentTaskId) {
|
||||
const projectSettingsResult = await db.query(
|
||||
"SELECT use_time_progress FROM projects WHERE id = $1",
|
||||
[projectId]
|
||||
);
|
||||
|
||||
const useTimeProgress = projectSettingsResult.rows[0]?.use_time_progress;
|
||||
|
||||
if (useTimeProgress) {
|
||||
// Recalculate parent task progress when subtask time estimation changes
|
||||
await updateTaskAncestors(io, socket, projectId, parentTaskId);
|
||||
}
|
||||
}
|
||||
|
||||
notifyProjectUpdates(socket, d.id);
|
||||
|
||||
logTotalMinutes({
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
import { Socket } from "socket.io";
|
||||
import db from "../../config/db";
|
||||
import { SocketEvents } from "../events";
|
||||
import { log, log_error, notifyProjectUpdates } from "../util";
|
||||
import { logProgressChange } from "../../services/activity-logs/activity-logs.service";
|
||||
import TasksControllerV2 from "../../controllers/tasks-controller-v2";
|
||||
|
||||
interface UpdateTaskProgressData {
|
||||
task_id: string;
|
||||
progress_value: number;
|
||||
parent_task_id: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively updates all ancestor tasks' progress when a subtask changes
|
||||
* @param io Socket.io instance
|
||||
* @param socket Socket instance for emitting events
|
||||
* @param projectId Project ID for room broadcasting
|
||||
* @param taskId The task ID to update (starts with the parent task)
|
||||
*/
|
||||
async function updateTaskAncestors(io: any, socket: Socket, projectId: string, taskId: string | null) {
|
||||
if (!taskId) return;
|
||||
|
||||
try {
|
||||
// Use the new controller method to update the task progress
|
||||
await TasksControllerV2.updateTaskProgress(taskId);
|
||||
|
||||
// Get the current task's progress ratio
|
||||
const progressRatio = await db.query(
|
||||
"SELECT get_task_complete_ratio($1) as ratio",
|
||||
[taskId]
|
||||
);
|
||||
|
||||
const ratio = progressRatio?.rows[0]?.ratio?.ratio || 0;
|
||||
console.log(`Updated task ${taskId} progress: ${ratio}`);
|
||||
|
||||
// Check if this task needs a "done" status prompt
|
||||
let shouldPromptForDone = false;
|
||||
|
||||
if (ratio >= 100) {
|
||||
// Get the task's current status
|
||||
const taskStatusResult = await db.query(`
|
||||
SELECT ts.id, stsc.is_done
|
||||
FROM tasks t
|
||||
JOIN task_statuses ts ON t.status_id = ts.id
|
||||
JOIN sys_task_status_categories stsc ON ts.category_id = stsc.id
|
||||
WHERE t.id = $1
|
||||
`, [taskId]);
|
||||
|
||||
// If the task isn't already in a "done" category, we should prompt the user
|
||||
if (taskStatusResult.rows.length > 0 && !taskStatusResult.rows[0].is_done) {
|
||||
shouldPromptForDone = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Emit the updated progress
|
||||
socket.emit(
|
||||
SocketEvents.TASK_PROGRESS_UPDATED.toString(),
|
||||
{
|
||||
task_id: taskId,
|
||||
progress_value: ratio,
|
||||
should_prompt_for_done: shouldPromptForDone
|
||||
}
|
||||
);
|
||||
|
||||
// Find this task's parent to continue the recursive update
|
||||
const parentResult = await db.query(
|
||||
"SELECT parent_task_id FROM tasks WHERE id = $1",
|
||||
[taskId]
|
||||
);
|
||||
|
||||
const parentTaskId = parentResult.rows[0]?.parent_task_id;
|
||||
|
||||
// If there's a parent, recursively update it
|
||||
if (parentTaskId) {
|
||||
await updateTaskAncestors(io, socket, projectId, parentTaskId);
|
||||
}
|
||||
} catch (error) {
|
||||
log_error(`Error updating ancestor task ${taskId}: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function on_update_task_progress(io: any, socket: Socket, data: string) {
|
||||
try {
|
||||
const parsedData = JSON.parse(data) as UpdateTaskProgressData;
|
||||
const { task_id, progress_value, parent_task_id } = parsedData;
|
||||
|
||||
if (!task_id || progress_value === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if this is a parent task (has subtasks)
|
||||
const subTasksResult = await db.query(
|
||||
"SELECT COUNT(*) as subtask_count FROM tasks WHERE parent_task_id = $1",
|
||||
[task_id]
|
||||
);
|
||||
|
||||
const subtaskCount = parseInt(subTasksResult.rows[0]?.subtask_count || "0");
|
||||
|
||||
// If this is a parent task, we shouldn't set manual progress
|
||||
if (subtaskCount > 0) {
|
||||
log_error(`Cannot set manual progress on parent task ${task_id} with ${subtaskCount} subtasks`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the current progress value to log the change
|
||||
const currentProgressResult = await db.query(
|
||||
"SELECT progress_value, project_id, status_id FROM tasks WHERE id = $1",
|
||||
[task_id]
|
||||
);
|
||||
|
||||
const currentProgress = currentProgressResult.rows[0]?.progress_value;
|
||||
const projectId = currentProgressResult.rows[0]?.project_id;
|
||||
const statusId = currentProgressResult.rows[0]?.status_id;
|
||||
|
||||
// Update the task progress in the database
|
||||
await db.query(
|
||||
`UPDATE tasks
|
||||
SET progress_value = $1, manual_progress = true, updated_at = NOW()
|
||||
WHERE id = $2`,
|
||||
[progress_value, task_id]
|
||||
);
|
||||
|
||||
// Log the progress change using the activity logs service
|
||||
await logProgressChange({
|
||||
task_id,
|
||||
old_value: currentProgress !== null ? currentProgress.toString() : "0",
|
||||
new_value: progress_value.toString(),
|
||||
socket
|
||||
});
|
||||
|
||||
if (projectId) {
|
||||
// Check if progress is 100% and the task isn't already in a "done" status category
|
||||
let shouldPromptForDone = false;
|
||||
|
||||
if (progress_value >= 100) {
|
||||
// Check if the task's current status is in a "done" category
|
||||
const statusCategoryResult = await db.query(`
|
||||
SELECT stsc.is_done
|
||||
FROM task_statuses ts
|
||||
JOIN sys_task_status_categories stsc ON ts.category_id = stsc.id
|
||||
WHERE ts.id = $1
|
||||
`, [statusId]);
|
||||
|
||||
// If the task isn't already in a "done" category, we should prompt the user
|
||||
if (statusCategoryResult.rows.length > 0 && !statusCategoryResult.rows[0].is_done) {
|
||||
shouldPromptForDone = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Emit the update to all clients in the project room
|
||||
socket.emit(
|
||||
SocketEvents.TASK_PROGRESS_UPDATED.toString(),
|
||||
{
|
||||
task_id,
|
||||
progress_value,
|
||||
should_prompt_for_done: shouldPromptForDone
|
||||
}
|
||||
);
|
||||
|
||||
log(`Emitted progress update for task ${task_id} to project room ${projectId}`, null);
|
||||
|
||||
// If this task has a parent, use our controller to update all ancestors
|
||||
if (parent_task_id) {
|
||||
// Use the controller method to update the parent task's progress
|
||||
await TasksControllerV2.updateTaskProgress(parent_task_id);
|
||||
// Also use the existing method for socket notifications
|
||||
await updateTaskAncestors(io, socket, projectId, parent_task_id);
|
||||
}
|
||||
|
||||
// Notify that project updates are available
|
||||
notifyProjectUpdates(socket, task_id);
|
||||
}
|
||||
} catch (error) {
|
||||
log_error(error);
|
||||
}
|
||||
}
|
||||
107
worklenz-backend/src/socket.io/commands/on-update-task-weight.ts
Normal file
107
worklenz-backend/src/socket.io/commands/on-update-task-weight.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { Socket } from "socket.io";
|
||||
import db from "../../config/db";
|
||||
import { SocketEvents } from "../events";
|
||||
import { log, log_error, notifyProjectUpdates } from "../util";
|
||||
import { logWeightChange } from "../../services/activity-logs/activity-logs.service";
|
||||
import TasksControllerV2 from "../../controllers/tasks-controller-v2";
|
||||
|
||||
interface UpdateTaskWeightData {
|
||||
task_id: string;
|
||||
weight: number;
|
||||
parent_task_id: string | null;
|
||||
}
|
||||
|
||||
export async function on_update_task_weight(io: any, socket: Socket, data: string) {
|
||||
try {
|
||||
|
||||
const parsedData = JSON.parse(data) as UpdateTaskWeightData;
|
||||
const { task_id, weight, parent_task_id } = parsedData;
|
||||
|
||||
if (!task_id || weight === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the current weight value to log the change
|
||||
const currentWeightResult = await db.query(
|
||||
"SELECT weight, project_id FROM tasks WHERE id = $1",
|
||||
[task_id]
|
||||
);
|
||||
|
||||
const currentWeight = currentWeightResult.rows[0]?.weight;
|
||||
const projectId = currentWeightResult.rows[0]?.project_id;
|
||||
|
||||
// Update the task weight using our controller method
|
||||
await TasksControllerV2.updateTaskWeight(task_id, weight);
|
||||
|
||||
// Log the weight change using the activity logs service
|
||||
await logWeightChange({
|
||||
task_id,
|
||||
old_value: currentWeight !== null ? currentWeight.toString() : "100",
|
||||
new_value: weight.toString(),
|
||||
socket
|
||||
});
|
||||
|
||||
if (projectId) {
|
||||
// Emit the update to all clients in the project room
|
||||
socket.emit(
|
||||
SocketEvents.TASK_PROGRESS_UPDATED.toString(),
|
||||
{
|
||||
task_id,
|
||||
weight
|
||||
}
|
||||
);
|
||||
|
||||
// If this is a subtask, update the parent task's progress
|
||||
if (parent_task_id) {
|
||||
// Use the controller to update the parent task progress
|
||||
await TasksControllerV2.updateTaskProgress(parent_task_id);
|
||||
|
||||
// Get the updated progress to emit to clients
|
||||
const progressRatio = await db.query(
|
||||
"SELECT get_task_complete_ratio($1) as ratio",
|
||||
[parent_task_id]
|
||||
);
|
||||
|
||||
// Emit the parent task's updated progress
|
||||
socket.emit(
|
||||
SocketEvents.TASK_PROGRESS_UPDATED.toString(),
|
||||
{
|
||||
task_id: parent_task_id,
|
||||
progress_value: progressRatio?.rows[0]?.ratio?.ratio || 0
|
||||
}
|
||||
);
|
||||
|
||||
// We also need to update any grandparent tasks
|
||||
const grandparentResult = await db.query(
|
||||
"SELECT parent_task_id FROM tasks WHERE id = $1",
|
||||
[parent_task_id]
|
||||
);
|
||||
|
||||
const grandparentId = grandparentResult.rows[0]?.parent_task_id;
|
||||
|
||||
if (grandparentId) {
|
||||
await TasksControllerV2.updateTaskProgress(grandparentId);
|
||||
|
||||
// Emit the grandparent's updated progress
|
||||
const grandparentProgressRatio = await db.query(
|
||||
"SELECT get_task_complete_ratio($1) as ratio",
|
||||
[grandparentId]
|
||||
);
|
||||
|
||||
socket.emit(
|
||||
SocketEvents.TASK_PROGRESS_UPDATED.toString(),
|
||||
{
|
||||
task_id: grandparentId,
|
||||
progress_value: grandparentProgressRatio?.rows[0]?.ratio?.ratio || 0
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Notify that project updates are available
|
||||
notifyProjectUpdates(socket, task_id);
|
||||
}
|
||||
} catch (error) {
|
||||
log_error(error);
|
||||
}
|
||||
}
|
||||
@@ -57,4 +57,17 @@ export enum SocketEvents {
|
||||
TASK_ASSIGNEES_CHANGE,
|
||||
TASK_CUSTOM_COLUMN_UPDATE,
|
||||
CUSTOM_COLUMN_PINNED_CHANGE,
|
||||
TEAM_MEMBER_ROLE_CHANGE,
|
||||
|
||||
// Task progress events
|
||||
UPDATE_TASK_PROGRESS,
|
||||
UPDATE_TASK_WEIGHT,
|
||||
TASK_PROGRESS_UPDATED,
|
||||
|
||||
// Task subtasks count events
|
||||
GET_TASK_SUBTASKS_COUNT,
|
||||
TASK_SUBTASKS_COUNT,
|
||||
|
||||
// Task completion events
|
||||
GET_DONE_STATUSES,
|
||||
}
|
||||
|
||||
@@ -52,6 +52,10 @@ import { on_task_recurring_change } from "./commands/on-task-recurring-change";
|
||||
import { on_task_assignees_change } from "./commands/on-task-assignees-change";
|
||||
import { on_task_custom_column_update } from "./commands/on_custom_column_update";
|
||||
import { on_custom_column_pinned_change } from "./commands/on_custom_column_pinned_change";
|
||||
import { on_update_task_progress } from "./commands/on-update-task-progress";
|
||||
import { on_update_task_weight } from "./commands/on-update-task-weight";
|
||||
import { on_get_task_subtasks_count } from "./commands/on-get-task-subtasks-count";
|
||||
import { on_get_done_statuses } from "./commands/on-get-done-statuses";
|
||||
|
||||
export function register(io: any, socket: Socket) {
|
||||
log(socket.id, "client registered");
|
||||
@@ -69,7 +73,6 @@ export function register(io: any, socket: Socket) {
|
||||
socket.on(SocketEvents.TASK_TIME_ESTIMATION_CHANGE.toString(), data => on_time_estimation_change(io, socket, data));
|
||||
socket.on(SocketEvents.TASK_DESCRIPTION_CHANGE.toString(), data => on_task_description_change(io, socket, data));
|
||||
socket.on(SocketEvents.GET_TASK_PROGRESS.toString(), data => on_get_task_progress(io, socket, data));
|
||||
socket.on(SocketEvents.GET_TASK_PROGRESS.toString(), data => on_get_task_progress(io, socket, data));
|
||||
socket.on(SocketEvents.TASK_TIMER_START.toString(), data => on_task_timer_start(io, socket, data));
|
||||
socket.on(SocketEvents.TASK_TIMER_STOP.toString(), data => on_task_timer_stop(io, socket, data));
|
||||
socket.on(SocketEvents.TASK_SORT_ORDER_CHANGE.toString(), data => on_task_sort_order_change(io, socket, data));
|
||||
@@ -106,6 +109,10 @@ export function register(io: any, socket: Socket) {
|
||||
socket.on(SocketEvents.TASK_ASSIGNEES_CHANGE.toString(), data => on_task_assignees_change(io, socket, data));
|
||||
socket.on(SocketEvents.TASK_CUSTOM_COLUMN_UPDATE.toString(), data => on_task_custom_column_update(io, socket, data));
|
||||
socket.on(SocketEvents.CUSTOM_COLUMN_PINNED_CHANGE.toString(), data => on_custom_column_pinned_change(io, socket, data));
|
||||
socket.on(SocketEvents.UPDATE_TASK_PROGRESS.toString(), data => on_update_task_progress(io, socket, data));
|
||||
socket.on(SocketEvents.UPDATE_TASK_WEIGHT.toString(), data => on_update_task_weight(io, socket, data));
|
||||
socket.on(SocketEvents.GET_TASK_SUBTASKS_COUNT.toString(), (taskId) => on_get_task_subtasks_count(io, socket, taskId));
|
||||
socket.on(SocketEvents.GET_DONE_STATUSES.toString(), (projectId, callback) => on_get_done_statuses(io, socket, projectId, callback));
|
||||
|
||||
// socket.io built-in event
|
||||
socket.on("disconnect", (reason) => on_disconnect(io, socket, reason));
|
||||
|
||||
@@ -2,31 +2,30 @@
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<title></title>
|
||||
<title>Password Changed | Worklenz</title>
|
||||
<meta content="text/html; charset=utf-8" http-equiv="Content-Type">
|
||||
<meta content="width=device-width,initial-scale=1" name="viewport">
|
||||
<style>
|
||||
* {
|
||||
box-sizing: border-box
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0
|
||||
padding: 0;
|
||||
background: linear-gradient(135deg, #f5f8ff 0%, #eaf0fb 100%);
|
||||
-webkit-text-size-adjust: none;
|
||||
text-size-adjust: none;
|
||||
font-family: 'Mada', Arial, sans-serif;
|
||||
}
|
||||
|
||||
a[x-apple-data-detectors] {
|
||||
color: inherit !important;
|
||||
text-decoration: inherit !important
|
||||
}
|
||||
|
||||
#MessageViewBody a {
|
||||
color: inherit;
|
||||
text-decoration: none
|
||||
}
|
||||
|
||||
p {
|
||||
line-height: inherit
|
||||
.main-container {
|
||||
background: #fff;
|
||||
border-radius: 18px;
|
||||
box-shadow: 0 4px 24px rgba(73, 146, 240, 0.10);
|
||||
margin: 40px auto 0 auto;
|
||||
max-width: 500px;
|
||||
padding: 0 0 20px 0;
|
||||
}
|
||||
|
||||
.padding-30 {
|
||||
@@ -42,33 +41,48 @@
|
||||
mso-hide: all;
|
||||
display: none;
|
||||
max-height: 0;
|
||||
overflow: hidden
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.footer {
|
||||
text-align: center;
|
||||
color: #b0b8c9;
|
||||
font-size: 13px;
|
||||
margin-top: 30px;
|
||||
padding-bottom: 18px;
|
||||
}
|
||||
|
||||
@media (max-width: 525px) {
|
||||
.main-container {
|
||||
margin: 0;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
max-width: 100% !important;
|
||||
}
|
||||
|
||||
.desktop_hide table.icons-inner {
|
||||
display: inline-block !important
|
||||
display: inline-block !important;
|
||||
}
|
||||
|
||||
.icons-inner {
|
||||
text-align: center
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.icons-inner td {
|
||||
margin: 0 auto
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.row-content {
|
||||
width: 95% !important
|
||||
width: 95% !important;
|
||||
}
|
||||
|
||||
.mobile_hide {
|
||||
display: none
|
||||
display: none;
|
||||
}
|
||||
|
||||
.stack .column {
|
||||
width: 100%;
|
||||
display: block
|
||||
display: block;
|
||||
}
|
||||
|
||||
.mobile_hide {
|
||||
@@ -76,135 +90,145 @@
|
||||
max-height: 0;
|
||||
max-width: 0;
|
||||
overflow: hidden;
|
||||
font-size: 0
|
||||
font-size: 0;
|
||||
}
|
||||
|
||||
.desktop_hide,
|
||||
.desktop_hide table {
|
||||
display: table !important;
|
||||
max-height: none !important
|
||||
max-height: none !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Mada:wght@500;700&display=swap" rel="stylesheet">
|
||||
</head>
|
||||
|
||||
<body style="background-color:#fff;margin:0;padding:0;-webkit-text-size-adjust:none;text-size-adjust:none;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" class="nl-container" role="presentation"
|
||||
style="mso-table-lspace:0;mso-table-rspace:0; background-image:none;background-position:top left;background-size:auto;background-repeat:no-repeat"
|
||||
width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" class="row row-1" role="presentation"
|
||||
style="mso-table-lspace:0;mso-table-rspace:0" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
|
||||
<table border="0" cellpadding="0" cellspacing="0" class="image_block block-1 padding-20"
|
||||
role="presentation" style="mso-table-lspace:0;mso-table-rspace:0;margin-left: auto;margin-right: auto;padding-top: 20px;
|
||||
<div class="main-container">
|
||||
<table border="0" cellpadding="0" cellspacing="0" class="nl-container" role="presentation"
|
||||
style="mso-table-lspace:0;mso-table-rspace:0; background-image:none;background-position:top left;background-size:auto;background-repeat:no-repeat"
|
||||
width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" class="row row-1" role="presentation"
|
||||
style="mso-table-lspace:0;mso-table-rspace:0" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<table border="0" cellpadding="0" cellspacing="0" class="image_block block-1 padding-20"
|
||||
role="presentation" style="mso-table-lspace:0;mso-table-rspace:0;margin-left: auto;margin-right: auto;padding-top: 20px;
|
||||
padding-bottom: 20px;" width="220">
|
||||
<tr>
|
||||
<td class="pad" style="width:100%;padding-right:0;padding-left:0">
|
||||
<div align="left" class="alignment" style="line-height:10px">
|
||||
<a href="www.worklenz.com" style="outline:none;width: 170px;" tabindex="-1"
|
||||
target="_blank"><img
|
||||
src="https://worklenz.s3.amazonaws.com/email-templates-assets/email-logo.png"
|
||||
style="display:block;height:auto;border:0;max-width:100%;margin-top: 10px;margin-bottom: 5px;"></a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" class="row-content stack"
|
||||
role="presentation"
|
||||
style="mso-table-lspace:0;mso-table-rspace:0;color:#000;width:320px;margin-bottom: 40px;"
|
||||
width="320">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="column column-1"
|
||||
style="mso-table-lspace:0;mso-table-rspace:0;font-weight:400;text-align:left;vertical-align:top;padding-top:5px;padding-bottom:5px;border-top:0;border-right:0;border-bottom:0;border-left:0"
|
||||
width="100%">
|
||||
<table border="0" cellpadding="5" cellspacing="0" class="paragraph_block block-4 padding-30"
|
||||
role="presentation" style="mso-table-lspace:0;mso-table-rspace:0;word-break:break-word"
|
||||
<td class="pad" style="width:100%;padding-right:0;padding-left:0">
|
||||
<div align="left" class="alignment" style="line-height:10px">
|
||||
<a href="www.worklenz.com" style="outline:none;width: 170px;" tabindex="-1"
|
||||
target="_blank"><img
|
||||
src="https://worklenz.s3.amazonaws.com/email-templates-assets/email-logo.png"
|
||||
style="display:block;height:auto;border:0;max-width:100%;margin-top: 10px;margin-bottom: 5px;"></a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" class="row-content stack"
|
||||
role="presentation"
|
||||
style="mso-table-lspace:0;mso-table-rspace:0;color:#000;width:320px;margin-bottom: 40px;"
|
||||
width="320">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="column column-1"
|
||||
style="mso-table-lspace:0;mso-table-rspace:0;font-weight:400;text-align:left;vertical-align:top;padding-top:5px;padding-bottom:5px;border-top:0;border-right:0;border-bottom:0;border-left:0"
|
||||
width="100%">
|
||||
|
||||
<table border="0" cellpadding="5" cellspacing="0" class="paragraph_block block-4 padding-30"
|
||||
role="presentation" style="mso-table-lspace:0;mso-table-rspace:0;word-break:break-word"
|
||||
width="100%">
|
||||
<tr>
|
||||
<td class="pad" style="width:100%;padding-right:0;padding-left:0">
|
||||
<div align="center" class="alignment" style="line-height:10px;margin-top: 30px;">
|
||||
<img
|
||||
src="https://worklenz.s3.amazonaws.com/email-templates-assets/email-password-changed.png"
|
||||
style="display:block;height:auto;border:0;width:100px;max-width:100%;margin-bottom: 10px;"
|
||||
width="100">
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="pad">
|
||||
<h1
|
||||
style="margin:0;color:rgb(66, 66, 66);font-size:28px;line-height:120%;text-align:center;direction:ltr;font-weight:700;letter-spacing:0.5px;margin-top:10px;margin-bottom:0;padding-top: 10px;padding-bottom: 10px;font-family: 'Mada', Arial, sans-serif;">
|
||||
Password Changed Successfully
|
||||
</h1>
|
||||
<div
|
||||
style="color:#505771;font-size:19px;font-family: 'Mada', Arial, sans-serif;font-weight:500;line-height:150%;text-align:center;direction:ltr;letter-spacing:0.1px;mso-line-height-alt:24px;margin-top: 18px;">
|
||||
<p style="margin-top: 0px;margin-bottom: 18px;">Hi,</p>
|
||||
<p style="margin:0;margin-bottom:10px">This is a confirmation that your Worklenz
|
||||
account password was changed.</p>
|
||||
<p style="margin:0;margin-bottom:10px">If you did not make this change, please <a
|
||||
href="mailto:support@worklenz.com"
|
||||
style="color:#4992f0;text-decoration:none;">contact our support team</a>
|
||||
immediately.</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" class="row row-2" role="presentation"
|
||||
style="mso-table-lspace:0;mso-table-rspace:0" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" class="row-content stack" role="presentation"
|
||||
style="mso-table-lspace:0;mso-table-rspace:0;color:#000;width:475px" width="505">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="column column-1"
|
||||
style="mso-table-lspace:0;mso-table-rspace:0;font-weight:400;text-align:left;vertical-align:top;padding-top:5px;padding-bottom:5px;border-top:0;border-right:0;border-bottom:0;border-left:0"
|
||||
width="100%">
|
||||
<table border="0" cellpadding="0" cellspacing="0" class="icons_block block-1" role="presentation"
|
||||
style="mso-table-lspace:0;mso-table-rspace:0" width="100%">
|
||||
<tr>
|
||||
<td class="pad"
|
||||
style="vertical-align:middle;color:#9d9d9d;font-family:inherit;font-size:15px;padding-bottom:5px;padding-top:5px;text-align:center">
|
||||
<table cellpadding="0" cellspacing="0" role="presentation"
|
||||
style="mso-table-lspace:0;mso-table-rspace:0" width="100%">
|
||||
<tr>
|
||||
<td class="pad" style="width:100%;padding-right:0;padding-left:0">
|
||||
<div align="center" class="alignment" style="line-height:10px"><img
|
||||
src="https://worklenz.s3.amazonaws.com/email-templates-assets/email-password-changed.png"
|
||||
style="display:block;height:auto;border:0;width:100px;max-width:100%;margin-top: 30px;margin-bottom: 10px;"
|
||||
width="100">
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="pad">
|
||||
<div
|
||||
style="color:#505771;font-size:19px;@import url('https://fonts.googleapis.com/css2?family=Mada:wght@500&display=swap');font-family: 'Mada', sans-serif;font-weight:500;line-height:150%;text-align:center;direction:ltr;letter-spacing:0;mso-line-height-alt:24px">
|
||||
<p style="margin-top: 0px;margin-bottom: 30px;">
|
||||
We wanted to let you know that your Worklenz password was reset.
|
||||
</p>
|
||||
</div>
|
||||
<td class="alignment" style="vertical-align:middle;text-align:center">
|
||||
<!--[if vml]>
|
||||
<table align="left" cellpadding="0" cellspacing="0" role="presentation"
|
||||
style="display:inline-block;padding-left:0px;padding-right:0px;mso-table-lspace: 0pt;mso-table-rspace: 0pt;">
|
||||
<![endif]-->
|
||||
<!--[if !vml]><!-->
|
||||
<table cellpadding="0" cellspacing="0" class="icons-inner" role="presentation"
|
||||
style="mso-table-lspace:0;mso-table-rspace:0;display:inline-block;margin-right:-4px;padding-left:0;padding-right:0">
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" class="row row-2" role="presentation"
|
||||
style="mso-table-lspace:0;mso-table-rspace:0" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" class="row-content stack" role="presentation"
|
||||
style="mso-table-lspace:0;mso-table-rspace:0;color:#000;width:475px" width="505">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="column column-1"
|
||||
style="mso-table-lspace:0;mso-table-rspace:0;font-weight:400;text-align:left;vertical-align:top;padding-top:5px;padding-bottom:5px;border-top:0;border-right:0;border-bottom:0;border-left:0"
|
||||
width="100%">
|
||||
<table border="0" cellpadding="0" cellspacing="0" class="icons_block block-1" role="presentation"
|
||||
style="mso-table-lspace:0;mso-table-rspace:0" width="100%">
|
||||
<tr>
|
||||
<td class="pad"
|
||||
style="vertical-align:middle;color:#9d9d9d;font-family:inherit;font-size:15px;padding-bottom:5px;padding-top:5px;text-align:center">
|
||||
<table cellpadding="0" cellspacing="0" role="presentation"
|
||||
style="mso-table-lspace:0;mso-table-rspace:0" width="100%">
|
||||
<tr>
|
||||
<td class="alignment" style="vertical-align:middle;text-align:center">
|
||||
<!--[if vml]>
|
||||
<table align="left" cellpadding="0" cellspacing="0" role="presentation"
|
||||
style="display:inline-block;padding-left:0px;padding-right:0px;mso-table-lspace: 0pt;mso-table-rspace: 0pt;">
|
||||
<![endif]-->
|
||||
<!--[if !vml]><!-->
|
||||
<table cellpadding="0" cellspacing="0" class="icons-inner" role="presentation"
|
||||
style="mso-table-lspace:0;mso-table-rspace:0;display:inline-block;margin-right:-4px;padding-left:0;padding-right:0">
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table><!-- End -->
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="footer">
|
||||
If you have any questions, contact us at <a href="mailto:support@worklenz.com"
|
||||
style="color:#4992f0;text-decoration:none;">support@worklenz.com</a>.<br>
|
||||
© 2025 Worklenz. All rights reserved.
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
||||
@@ -2,31 +2,30 @@
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<title></title>
|
||||
<title>Reset Your Password | Worklenz</title>
|
||||
<meta content="text/html; charset=utf-8" http-equiv="Content-Type">
|
||||
<meta content="width=device-width,initial-scale=1" name="viewport">
|
||||
<style>
|
||||
* {
|
||||
box-sizing: border-box
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0
|
||||
padding: 0;
|
||||
background: linear-gradient(135deg, #f5f8ff 0%, #eaf0fb 100%);
|
||||
-webkit-text-size-adjust: none;
|
||||
text-size-adjust: none;
|
||||
font-family: 'Mada', Arial, sans-serif;
|
||||
}
|
||||
|
||||
a[x-apple-data-detectors] {
|
||||
color: inherit !important;
|
||||
text-decoration: inherit !important
|
||||
}
|
||||
|
||||
#MessageViewBody a {
|
||||
color: inherit;
|
||||
text-decoration: none
|
||||
}
|
||||
|
||||
p {
|
||||
line-height: inherit
|
||||
.main-container {
|
||||
background: #fff;
|
||||
border-radius: 18px;
|
||||
box-shadow: 0 4px 24px rgba(73, 146, 240, 0.10);
|
||||
margin: 40px auto 0 auto;
|
||||
max-width: 500px;
|
||||
padding: 0 0 20px 0;
|
||||
}
|
||||
|
||||
.padding-30 {
|
||||
@@ -42,33 +41,68 @@
|
||||
mso-hide: all;
|
||||
display: none;
|
||||
max-height: 0;
|
||||
overflow: hidden
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.modern-btn {
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
color: #fff;
|
||||
background: linear-gradient(90deg, #54bf6b 0%, #4992f0d9 100%);
|
||||
border-radius: 6px;
|
||||
font-weight: 600;
|
||||
padding: 10px 32px;
|
||||
font-size: 17px;
|
||||
box-shadow: 0 2px 8px rgba(73, 146, 240, 0.15);
|
||||
transition: background 0.2s, box-shadow 0.2s;
|
||||
margin-top: 10px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.modern-btn:hover {
|
||||
background: linear-gradient(90deg, #4992f0d9 0%, #54bf6b 100%);
|
||||
box-shadow: 0 4px 16px rgba(73, 146, 240, 0.18);
|
||||
}
|
||||
|
||||
.footer {
|
||||
text-align: center;
|
||||
color: #b0b8c9;
|
||||
font-size: 13px;
|
||||
margin-top: 30px;
|
||||
padding-bottom: 18px;
|
||||
}
|
||||
|
||||
@media (max-width: 525px) {
|
||||
.main-container {
|
||||
margin: 0;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
max-width: 100% !important;
|
||||
}
|
||||
|
||||
.desktop_hide table.icons-inner {
|
||||
display: inline-block !important
|
||||
display: inline-block !important;
|
||||
}
|
||||
|
||||
.icons-inner {
|
||||
text-align: center
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.icons-inner td {
|
||||
margin: 0 auto
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.row-content {
|
||||
width: 95% !important
|
||||
width: 95% !important;
|
||||
}
|
||||
|
||||
.mobile_hide {
|
||||
display: none
|
||||
display: none;
|
||||
}
|
||||
|
||||
.stack .column {
|
||||
width: 100%;
|
||||
display: block
|
||||
display: block;
|
||||
}
|
||||
|
||||
.mobile_hide {
|
||||
@@ -76,179 +110,137 @@
|
||||
max-height: 0;
|
||||
max-width: 0;
|
||||
overflow: hidden;
|
||||
font-size: 0
|
||||
font-size: 0;
|
||||
}
|
||||
|
||||
.desktop_hide,
|
||||
.desktop_hide table {
|
||||
display: table !important;
|
||||
max-height: none !important
|
||||
max-height: none !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Mada:wght@500;700&display=swap" rel="stylesheet">
|
||||
</head>
|
||||
|
||||
<body style="background-color:#fff;margin:0;padding:0;-webkit-text-size-adjust:none;text-size-adjust:none">
|
||||
<table border="0" cellpadding="0" cellspacing="0" class="nl-container" role="presentation"
|
||||
style="mso-table-lspace:0;mso-table-rspace:0; background-image:none;background-position:top left;background-size:auto;background-repeat:no-repeat"
|
||||
width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" class="row row-1" role="presentation"
|
||||
style="mso-table-lspace:0;mso-table-rspace:0" width="100%">
|
||||
<tbody>
|
||||
<body>
|
||||
<div class="main-container">
|
||||
<table border="0" cellpadding="0" cellspacing="0" class="nl-container" role="presentation" style="background:transparent;" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
|
||||
<table border="0" cellpadding="0" cellspacing="0" class="image_block block-1 padding-20" role="presentation"
|
||||
style="mso-table-lspace:0;mso-table-rspace:0;margin-left: auto;margin-right: auto;padding-top: 20px;
|
||||
padding-bottom: 20px;" width="220">
|
||||
<tr>
|
||||
<td class="pad" style="width:100%;padding-right:0;padding-left:0">
|
||||
<div align="left" class="alignment" style="line-height:10px">
|
||||
<a href="www.worklenz.com" style="outline:none;width: 170px;" tabindex="-1"
|
||||
target="_blank"><img
|
||||
src="https://worklenz.s3.amazonaws.com/email-templates-assets/email-logo.png"
|
||||
style="display:block;height:auto;border:0;max-width:100%;margin-top: 10px;margin-bottom: 5px;"></a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" class="row-content stack"
|
||||
role="presentation"
|
||||
style="mso-table-lspace:0;mso-table-rspace:0;color:#000;width:475px;"
|
||||
width="475">
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" class="row row-1" role="presentation" style="mso-table-lspace:0;mso-table-rspace:0" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="column column-1"
|
||||
style="mso-table-lspace:0;mso-table-rspace:0;font-weight:400;text-align:left;vertical-align:top;padding-top:5px;padding-bottom:5px;border-top:0;border-right:0;border-bottom:0;border-left:0"
|
||||
width="100%">
|
||||
<table border="0" cellpadding="0" cellspacing="0" class="image_block block-3" role="presentation"
|
||||
style="mso-table-lspace:0;mso-table-rspace:0" width="100%">
|
||||
<tr>
|
||||
<td class="pad">
|
||||
<h1
|
||||
style="margin:0;color:rgb(66, 66, 66);font-size:26px;line-height:120%;text-align:center;direction:ltr;font-weight:700;letter-spacing:normal;margin-top:30px;margin-bottom:0;padding-top: 20px;padding-bottom: 20px;@import url('https://fonts.googleapis.com/css2?family=Mada:wght@500&display=swap');font-family: 'Mada', sans-serif;">
|
||||
<span class="tinyMce-placeholder">Reset your password on Worklenz</span>
|
||||
</h1>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="pad" style="width:100%;padding-right:0;padding-left:0">
|
||||
<div align="center" class="alignment" style="line-height:10px"><img
|
||||
src="https://worklenz.s3.amazonaws.com/email-templates-assets/email-new-team.png"
|
||||
style="display:block;height:auto;border:0;width:180px;max-width:100%;margin-top: 30px;margin-bottom: 10px;"
|
||||
width="180">
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<table border="0" cellpadding="5" cellspacing="0" class="paragraph_block block-4 padding-30"
|
||||
role="presentation" style="mso-table-lspace:0;mso-table-rspace:0;word-break:break-word"
|
||||
width="100%">
|
||||
<tr>
|
||||
<td class="pad">
|
||||
<div
|
||||
style="color:#505771;font-size:19px;@import url('https://fonts.googleapis.com/css2?family=Mada:wght@500&display=swap');font-family: 'Mada', sans-serif;font-weight:500;line-height:120%;text-align:center;direction:ltr;letter-spacing:0;mso-line-height-alt:24px">
|
||||
<p style="margin-top: 0px;margin-bottom: 2px;">Hi,</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<table border="0" cellpadding="8" cellspacing="0" class="paragraph_block block-5 padding-30"
|
||||
role="presentation" style="mso-table-lspace:0;mso-table-rspace:0;word-break:break-word"
|
||||
width="100%">
|
||||
<tr>
|
||||
<td class="pad">
|
||||
<div
|
||||
style="color:rgb(143,152,164);font-size:16px;@import url('https://fonts.googleapis.com/css2?family=Mada:wght@500&display=swap');font-family: 'Mada', sans-serif;font-weight:400;line-height:135%;text-align:center;direction:ltr;letter-spacing:0;mso-line-height-alt:18px">
|
||||
<p style="margin:0;margin-bottom:10px">You have requested to reset your password
|
||||
</p>
|
||||
<p style="margin:0;margin-bottom:10px">To reset your password, click the following link and follow the instructions.</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<table border="0" cellpadding="10" cellspacing="0" class="button_block block-6" role="presentation"
|
||||
style="mso-table-lspace:0;mso-table-rspace:0;margin-top: 10px;margin-bottom: 30px;"
|
||||
width="100%">
|
||||
<tr>
|
||||
<td class="pad">
|
||||
<div align="center" class="alignment">
|
||||
<a href="https://[VAR_HOSTNAME]/auth/verify-reset-email/[VAR_USER_ID]/[VAR_HASH]">
|
||||
<div
|
||||
style="text-decoration:none;display:inline-block;color:#fff;background: #54bf6b;border-radius:3px;width:auto;font-weight:400;padding-top:6px;padding-bottom:7px;@import url('https://fonts.googleapis.com/css2?family=Mada:wght@500&display=swap');font-family: 'Mada', sans-serif;text-align:center;mso-border-alt:none;word-break:keep-all">
|
||||
<span
|
||||
style="padding-left:25px;padding-right:25px;font-size:16px;display:inline-block;letter-spacing:normal;"><span
|
||||
dir="ltr" style="word-break: break-word; line-height: 28px;">Reset my password</span></span>
|
||||
</div>
|
||||
</a>
|
||||
<!--[if mso]></center></v:textbox></v:roundrect><![endif]-->
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<tr>
|
||||
<td>
|
||||
<table border="0" cellpadding="0" cellspacing="0" class="image_block block-1 padding-20" role="presentation" style="mso-table-lspace:0;mso-table-rspace:0;margin-left: auto;margin-right: auto;padding-top: 20px; padding-bottom: 20px;" width="220">
|
||||
<tr>
|
||||
<td class="pad" style="width:100%;padding-right:0;padding-left:0">
|
||||
<div align="left" class="alignment" style="line-height:10px">
|
||||
<a href="www.worklenz.com" style="outline:none;width: 170px;" tabindex="-1" target="_blank"><img src="https://worklenz.s3.amazonaws.com/email-templates-assets/email-logo.png" style="display:block;height:auto;border:0;max-width:100%;margin-top: 10px;margin-bottom: 5px;"></a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" class="row-content stack" role="presentation" style="mso-table-lspace:0;mso-table-rspace:0;color:#000;width:475px;" width="475">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="column column-1" style="mso-table-lspace:0;mso-table-rspace:0;font-weight:400;text-align:left;vertical-align:top;padding-top:5px;padding-bottom:5px;border-top:0;border-right:0;border-bottom:0;border-left:0" width="100%">
|
||||
<table border="0" cellpadding="0" cellspacing="0" class="image_block block-3" role="presentation" style="mso-table-lspace:0;mso-table-rspace:0" width="100%">
|
||||
<tr>
|
||||
<td class="pad">
|
||||
<h1 style="margin:0;color:rgb(66, 66, 66);font-size:28px;line-height:120%;text-align:center;direction:ltr;font-weight:700;letter-spacing:0.5px;margin-top:30px;margin-bottom:0;padding-top: 20px;padding-bottom: 20px;font-family: 'Mada', Arial, sans-serif;">
|
||||
<span class="tinyMce-placeholder">Reset your password</span>
|
||||
</h1>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="pad" style="width:100%;padding-right:0;padding-left:0">
|
||||
<div align="center" class="alignment" style="line-height:10px"><img src="https://worklenz.s3.amazonaws.com/email-templates-assets/email-new-team.png" style="display:block;height:auto;border:0;width:180px;max-width:100%;margin-top: 30px;margin-bottom: 10px;" width="180">
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<table border="0" cellpadding="5" cellspacing="0" class="paragraph_block block-4 padding-30" role="presentation" style="mso-table-lspace:0;mso-table-rspace:0;word-break:break-word" width="100%">
|
||||
<tr>
|
||||
<td class="pad">
|
||||
<div style="color:#505771;font-size:20px;font-family: 'Mada', Arial, sans-serif;font-weight:500;line-height:130%;text-align:center;direction:ltr;letter-spacing:0.1px;mso-line-height-alt:24px">
|
||||
<p style="margin-top: 0px;margin-bottom: 2px;">Hi,</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<table border="0" cellpadding="8" cellspacing="0" class="paragraph_block block-5 padding-30" role="presentation" style="mso-table-lspace:0;mso-table-rspace:0;word-break:break-word" width="100%">
|
||||
<tr>
|
||||
<td class="pad">
|
||||
<div style="color:rgb(143,152,164);font-size:16px;font-family: 'Mada', Arial, sans-serif;font-weight:400;line-height:145%;text-align:center;direction:ltr;letter-spacing:0.1px;mso-line-height-alt:18px">
|
||||
<p style="margin:0;margin-bottom:10px">We received a request to reset your Worklenz account password.</p>
|
||||
<p style="margin:0;margin-bottom:10px">Click the button below to set a new password. If you did not request this, you can safely ignore this email.</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<table border="0" cellpadding="10" cellspacing="0" class="button_block block-6" role="presentation" style="mso-table-lspace:0;mso-table-rspace:0;margin-top: 10px;margin-bottom: 30px;" width="100%">
|
||||
<tr>
|
||||
<td class="pad">
|
||||
<div align="center" class="alignment">
|
||||
<a href="https://[VAR_HOSTNAME]/auth/verify-reset-email/[VAR_USER_ID]/[VAR_HASH]" class="modern-btn">
|
||||
Reset my password
|
||||
</a>
|
||||
</div>
|
||||
<div style="color:#b0b8c9;font-size:14px;text-align:center;margin-top:10px;">
|
||||
<p style="margin:0;">For your security, this link will expire in 1 hour.</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" class="row row-2" role="presentation"
|
||||
style="mso-table-lspace:0;mso-table-rspace:0"
|
||||
width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" class="row-content stack"
|
||||
role="presentation"
|
||||
style="mso-table-lspace:0;mso-table-rspace:0;color:#000;width:475px" width="505">
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" class="row row-2" role="presentation" style="mso-table-lspace:0;mso-table-rspace:0" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="column column-1"
|
||||
style="mso-table-lspace:0;mso-table-rspace:0;font-weight:400;text-align:left;vertical-align:top;padding-top:5px;padding-bottom:5px;border-top:0;border-right:0;border-bottom:0;border-left:0"
|
||||
width="100%">
|
||||
<table border="0" cellpadding="0" cellspacing="0" class="icons_block block-1" role="presentation"
|
||||
style="mso-table-lspace:0;mso-table-rspace:0" width="100%">
|
||||
<tr>
|
||||
<td class="pad"
|
||||
style="vertical-align:middle;color:#9d9d9d;font-family:inherit;font-size:15px;padding-bottom:5px;padding-top:5px;text-align:center">
|
||||
<table cellpadding="0" cellspacing="0" role="presentation"
|
||||
style="mso-table-lspace:0;mso-table-rspace:0"
|
||||
width="100%">
|
||||
<tr>
|
||||
<td class="alignment" style="vertical-align:middle;text-align:center">
|
||||
<!--[if vml]>
|
||||
<table align="left" cellpadding="0" cellspacing="0" role="presentation"
|
||||
style="display:inline-block;padding-left:0px;padding-right:0px;mso-table-lspace: 0pt;mso-table-rspace: 0pt;">
|
||||
<![endif]-->
|
||||
<!--[if !vml]><!-->
|
||||
<table cellpadding="0"
|
||||
cellspacing="0"
|
||||
class="icons-inner" role="presentation"
|
||||
style="mso-table-lspace:0;mso-table-rspace:0;display:inline-block;margin-right:-4px;padding-left:0;padding-right:0">
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" class="row-content stack" role="presentation" style="mso-table-lspace:0;mso-table-rspace:0;color:#000;width:475px" width="505">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="column column-1" style="mso-table-lspace:0;mso-table-rspace:0;font-weight:400;text-align:left;vertical-align:top;padding-top:5px;padding-bottom:5px;border-top:0;border-right:0;border-bottom:0;border-left:0" width="100%">
|
||||
<table border="0" cellpadding="0" cellspacing="0" class="icons_block block-1" role="presentation" style="mso-table-lspace:0;mso-table-rspace:0" width="100%">
|
||||
<tr>
|
||||
<td class="pad" style="vertical-align:middle;color:#9d9d9d;font-family:inherit;font-size:15px;padding-bottom:5px;padding-top:5px;text-align:center">
|
||||
<table cellpadding="0" cellspacing="0" role="presentation" style="mso-table-lspace:0;mso-table-rspace:0" width="100%">
|
||||
<tr>
|
||||
<td class="alignment" style="vertical-align:middle;text-align:center">
|
||||
<table cellpadding="0" cellspacing="0" class="icons-inner" role="presentation" style="mso-table-lspace:0;mso-table-rspace:0;display:inline-block;margin-right:-4px;padding-left:0;padding-right:0">
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table><!-- End -->
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="footer">
|
||||
If you have any questions, contact us at <a href="mailto:support@worklenz.com" style="color:#4992f0;text-decoration:none;">support@worklenz.com</a>.<br>
|
||||
© 2025 Worklenz. All rights reserved.
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
||||
@@ -2,31 +2,30 @@
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<title></title>
|
||||
<title>Join Your Team on Worklenz</title>
|
||||
<meta content="text/html; charset=utf-8" http-equiv="Content-Type">
|
||||
<meta content="width=device-width,initial-scale=1" name="viewport">
|
||||
<style>
|
||||
* {
|
||||
box-sizing: border-box
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0
|
||||
padding: 0;
|
||||
background: linear-gradient(135deg, #f5f8ff 0%, #eaf0fb 100%);
|
||||
-webkit-text-size-adjust: none;
|
||||
text-size-adjust: none;
|
||||
font-family: 'Mada', Arial, sans-serif;
|
||||
}
|
||||
|
||||
a[x-apple-data-detectors] {
|
||||
color: inherit !important;
|
||||
text-decoration: inherit !important
|
||||
}
|
||||
|
||||
#MessageViewBody a {
|
||||
color: inherit;
|
||||
text-decoration: none
|
||||
}
|
||||
|
||||
p {
|
||||
line-height: inherit
|
||||
.main-container {
|
||||
background: #fff;
|
||||
border-radius: 18px;
|
||||
box-shadow: 0 4px 24px rgba(73, 146, 240, 0.10);
|
||||
margin: 40px auto 0 auto;
|
||||
max-width: 500px;
|
||||
padding: 0 0 20px 0;
|
||||
}
|
||||
|
||||
.padding-30 {
|
||||
@@ -42,33 +41,68 @@
|
||||
mso-hide: all;
|
||||
display: none;
|
||||
max-height: 0;
|
||||
overflow: hidden
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.modern-btn {
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
color: #fff;
|
||||
background: linear-gradient(90deg, #54bf6b 0%, #4992f0d9 100%);
|
||||
border-radius: 6px;
|
||||
font-weight: 600;
|
||||
padding: 10px 32px;
|
||||
font-size: 17px;
|
||||
box-shadow: 0 2px 8px rgba(73, 146, 240, 0.15);
|
||||
transition: background 0.2s, box-shadow 0.2s;
|
||||
margin-top: 10px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.modern-btn:hover {
|
||||
background: linear-gradient(90deg, #4992f0d9 0%, #54bf6b 100%);
|
||||
box-shadow: 0 4px 16px rgba(73, 146, 240, 0.18);
|
||||
}
|
||||
|
||||
.footer {
|
||||
text-align: center;
|
||||
color: #b0b8c9;
|
||||
font-size: 13px;
|
||||
margin-top: 30px;
|
||||
padding-bottom: 18px;
|
||||
}
|
||||
|
||||
@media (max-width: 525px) {
|
||||
.main-container {
|
||||
margin: 0;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
max-width: 100% !important;
|
||||
}
|
||||
|
||||
.desktop_hide table.icons-inner {
|
||||
display: inline-block !important
|
||||
display: inline-block !important;
|
||||
}
|
||||
|
||||
.icons-inner {
|
||||
text-align: center
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.icons-inner td {
|
||||
margin: 0 auto
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.row-content {
|
||||
width: 95% !important
|
||||
width: 95% !important;
|
||||
}
|
||||
|
||||
.mobile_hide {
|
||||
display: none
|
||||
display: none;
|
||||
}
|
||||
|
||||
.stack .column {
|
||||
width: 100%;
|
||||
display: block
|
||||
display: block;
|
||||
}
|
||||
|
||||
.mobile_hide {
|
||||
@@ -76,181 +110,134 @@
|
||||
max-height: 0;
|
||||
max-width: 0;
|
||||
overflow: hidden;
|
||||
font-size: 0
|
||||
font-size: 0;
|
||||
}
|
||||
|
||||
.desktop_hide,
|
||||
.desktop_hide table {
|
||||
display: table !important;
|
||||
max-height: none !important
|
||||
max-height: none !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Mada:wght@500;700&display=swap" rel="stylesheet">
|
||||
</head>
|
||||
|
||||
<body style="background-color:#fff;margin:0;padding:0;-webkit-text-size-adjust:none;text-size-adjust:none">
|
||||
<table border="0" cellpadding="0" cellspacing="0" class="nl-container" role="presentation"
|
||||
style="mso-table-lspace:0;mso-table-rspace:0; background-image:none;background-position:top left;background-size:auto;background-repeat:no-repeat"
|
||||
width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" class="row row-1" role="presentation"
|
||||
style="mso-table-lspace:0;mso-table-rspace:0" width="100%">
|
||||
<tbody>
|
||||
<body>
|
||||
<div class="main-container">
|
||||
<table border="0" cellpadding="0" cellspacing="0" class="nl-container" role="presentation" style="background:transparent;" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
|
||||
<table border="0" cellpadding="0" cellspacing="0" class="image_block block-1 padding-20" role="presentation"
|
||||
style="mso-table-lspace:0;mso-table-rspace:0;margin-left: auto;margin-right: auto;padding-top: 20px;
|
||||
padding-bottom: 20px;" width="220">
|
||||
<tr>
|
||||
<td class="pad" style="width:100%;padding-right:0;padding-left:0">
|
||||
<div align="left" class="alignment" style="line-height:10px">
|
||||
<a href="www.worklenz.com" style="outline:none;width: 170px;" tabindex="-1"
|
||||
target="_blank"><img
|
||||
src="https://worklenz.s3.amazonaws.com/email-templates-assets/email-logo.png"
|
||||
style="display:block;height:auto;border:0;max-width:100%;margin-top: 10px;margin-bottom: 5px;"></a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" class="row-content stack"
|
||||
role="presentation"
|
||||
style="mso-table-lspace:0;mso-table-rspace:0;color:#000;width:475px;"
|
||||
width="475">
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" class="row row-1" role="presentation" style="mso-table-lspace:0;mso-table-rspace:0" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="column column-1"
|
||||
style="mso-table-lspace:0;mso-table-rspace:0;font-weight:400;text-align:left;vertical-align:top;padding-top:5px;padding-bottom:5px;border-top:0;border-right:0;border-bottom:0;border-left:0"
|
||||
width="100%">
|
||||
<table border="0" cellpadding="0" cellspacing="0" class="image_block block-3" role="presentation"
|
||||
style="mso-table-lspace:0;mso-table-rspace:0" width="100%">
|
||||
<tr>
|
||||
<td class="pad">
|
||||
<h1
|
||||
style="margin:0;color:rgb(66, 66, 66);font-size:26px;line-height:120%;text-align:center;direction:ltr;font-weight:700;letter-spacing:normal;margin-top:30px;margin-bottom:0;padding-top: 20px;padding-bottom: 20px;@import url('https://fonts.googleapis.com/css2?family=Mada:wght@500&display=swap');font-family: 'Mada', sans-serif;">
|
||||
<span class="tinyMce-placeholder">Join your team on Worklenz</span>
|
||||
</h1>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="pad" style="width:100%;padding-right:0;padding-left:0">
|
||||
<div align="center" class="alignment" style="line-height:10px"><img
|
||||
src="https://worklenz.s3.amazonaws.com/email-templates-assets/email-new-team.png"
|
||||
style="display:block;height:auto;border:0;width:180px;max-width:100%;margin-top: 30px;margin-bottom: 10px;"
|
||||
width="180">
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<table border="0" cellpadding="5" cellspacing="0" class="paragraph_block block-4 padding-30"
|
||||
role="presentation" style="mso-table-lspace:0;mso-table-rspace:0;word-break:break-word"
|
||||
width="100%">
|
||||
<tr>
|
||||
<td class="pad">
|
||||
<div
|
||||
style="color:#505771;font-size:19px;@import url('https://fonts.googleapis.com/css2?family=Mada:wght@500&display=swap');font-family: 'Mada', sans-serif;font-weight:500;line-height:120%;text-align:center;direction:ltr;letter-spacing:0;mso-line-height-alt:24px">
|
||||
<p style="margin-top: 0px;margin-bottom: 2px;">Hi [VAR_USER_NAME],</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<table border="0" cellpadding="8" cellspacing="0" class="paragraph_block block-5 padding-30"
|
||||
role="presentation" style="mso-table-lspace:0;mso-table-rspace:0;word-break:break-word"
|
||||
width="100%">
|
||||
<tr>
|
||||
<td class="pad">
|
||||
<div
|
||||
style="color:rgb(143,152,164);font-size:16px;@import url('https://fonts.googleapis.com/css2?family=Mada:wght@500&display=swap');font-family: 'Mada', sans-serif;font-weight:400;line-height:135%;text-align:center;direction:ltr;letter-spacing:0;mso-line-height-alt:18px">
|
||||
<p style="margin:0;margin-bottom:10px">You have been added to the "[VAR_TEAM_NAME]" team
|
||||
on Worklenz!
|
||||
</p>
|
||||
<p>Sign in to your Worklenz account to continue.</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<table border="0" cellpadding="10" cellspacing="0" class="button_block block-6" role="presentation"
|
||||
style="mso-table-lspace:0;mso-table-rspace:0;margin-top: 10px;margin-bottom: 30px;"
|
||||
width="100%">
|
||||
<tr>
|
||||
<td class="pad">
|
||||
<div align="center" class="alignment">
|
||||
<a href="https://[VAR_HOSTNAME]/auth/login?team=[VAR_TEAM_ID]&user=[VAR_USER_ID]&project=[PROJECT_ID]">
|
||||
<div
|
||||
style="text-decoration:none;display:inline-block;color:#fff;background: #54bf6b;border-radius:3px;width:auto;font-weight:400;padding-top:6px;padding-bottom:7px;@import url('https://fonts.googleapis.com/css2?family=Mada:wght@500&display=swap');font-family: 'Mada', sans-serif;text-align:center;mso-border-alt:none;word-break:keep-all">
|
||||
<span
|
||||
style="padding-left:25px;padding-right:25px;font-size:16px;display:inline-block;letter-spacing:normal;"><span
|
||||
dir="ltr" style="word-break: break-word; line-height: 28px;">Go to
|
||||
Worklenz</span></span>
|
||||
</div>
|
||||
</a>
|
||||
<!--[if mso]></center></v:textbox></v:roundrect><![endif]-->
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<tr>
|
||||
<td>
|
||||
<table border="0" cellpadding="0" cellspacing="0" class="image_block block-1 padding-20" role="presentation" style="mso-table-lspace:0;mso-table-rspace:0;margin-left: auto;margin-right: auto;padding-top: 20px; padding-bottom: 20px;" width="220">
|
||||
<tr>
|
||||
<td class="pad" style="width:100%;padding-right:0;padding-left:0">
|
||||
<div align="left" class="alignment" style="line-height:10px">
|
||||
<a href="www.worklenz.com" style="outline:none;width: 170px;" tabindex="-1" target="_blank"><img src="https://worklenz.s3.amazonaws.com/email-templates-assets/email-logo.png" style="display:block;height:auto;border:0;max-width:100%;margin-top: 10px;margin-bottom: 5px;"></a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" class="row-content stack" role="presentation" style="mso-table-lspace:0;mso-table-rspace:0;color:#000;width:475px;" width="475">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="column column-1" style="mso-table-lspace:0;mso-table-rspace:0;font-weight:400;text-align:left;vertical-align:top;padding-top:5px;padding-bottom:5px;border-top:0;border-right:0;border-bottom:0;border-left:0" width="100%">
|
||||
<table border="0" cellpadding="0" cellspacing="0" class="image_block block-3" role="presentation" style="mso-table-lspace:0;mso-table-rspace:0" width="100%">
|
||||
<tr>
|
||||
<td class="pad">
|
||||
<h1 style="margin:0;color:rgb(66, 66, 66);font-size:28px;line-height:120%;text-align:center;direction:ltr;font-weight:700;letter-spacing:0.5px;margin-top:30px;margin-bottom:0;padding-top: 20px;padding-bottom: 20px;font-family: 'Mada', Arial, sans-serif;">
|
||||
<span class="tinyMce-placeholder">Join your team on Worklenz</span>
|
||||
</h1>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="pad" style="width:100%;padding-right:0;padding-left:0">
|
||||
<div align="center" class="alignment" style="line-height:10px"><img src="https://worklenz.s3.amazonaws.com/email-templates-assets/email-new-team.png" style="display:block;height:auto;border:0;width:180px;max-width:100%;margin-top: 30px;margin-bottom: 10px;" width="180">
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<table border="0" cellpadding="5" cellspacing="0" class="paragraph_block block-4 padding-30" role="presentation" style="mso-table-lspace:0;mso-table-rspace:0;word-break:break-word" width="100%">
|
||||
<tr>
|
||||
<td class="pad">
|
||||
<div style="color:#505771;font-size:20px;font-family: 'Mada', Arial, sans-serif;font-weight:500;line-height:130%;text-align:center;direction:ltr;letter-spacing:0.1px;mso-line-height-alt:24px">
|
||||
<p style="margin-top: 0px;margin-bottom: 2px;">Hi [VAR_USER_NAME],</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<table border="0" cellpadding="8" cellspacing="0" class="paragraph_block block-5 padding-30" role="presentation" style="mso-table-lspace:0;mso-table-rspace:0;word-break:break-word" width="100%">
|
||||
<tr>
|
||||
<td class="pad">
|
||||
<div style="color:rgb(143,152,164);font-size:16px;font-family: 'Mada', Arial, sans-serif;font-weight:400;line-height:145%;text-align:center;direction:ltr;letter-spacing:0.1px;mso-line-height-alt:18px">
|
||||
<p style="margin:0;margin-bottom:10px">You have been added to the "[VAR_TEAM_NAME]" team on Worklenz!</p>
|
||||
<p>Sign in to your Worklenz account to continue.</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<table border="0" cellpadding="10" cellspacing="0" class="button_block block-6" role="presentation" style="mso-table-lspace:0;mso-table-rspace:0;margin-top: 10px;margin-bottom: 30px;" width="100%">
|
||||
<tr>
|
||||
<td class="pad">
|
||||
<div align="center" class="alignment">
|
||||
<a href="https://[VAR_HOSTNAME]/auth/login?team=[VAR_TEAM_ID]&user=[VAR_USER_ID]&project=[PROJECT_ID]" class="modern-btn">
|
||||
Go to Worklenz
|
||||
</a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" class="row row-2" role="presentation"
|
||||
style="mso-table-lspace:0;mso-table-rspace:0"
|
||||
width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" class="row-content stack"
|
||||
role="presentation"
|
||||
style="mso-table-lspace:0;mso-table-rspace:0;color:#000;width:475px" width="505">
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" class="row row-2" role="presentation" style="mso-table-lspace:0;mso-table-rspace:0" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="column column-1"
|
||||
style="mso-table-lspace:0;mso-table-rspace:0;font-weight:400;text-align:left;vertical-align:top;padding-top:5px;padding-bottom:5px;border-top:0;border-right:0;border-bottom:0;border-left:0"
|
||||
width="100%">
|
||||
<table border="0" cellpadding="0" cellspacing="0" class="icons_block block-1" role="presentation"
|
||||
style="mso-table-lspace:0;mso-table-rspace:0" width="100%">
|
||||
<tr>
|
||||
<td class="pad"
|
||||
style="vertical-align:middle;color:#9d9d9d;font-family:inherit;font-size:15px;padding-bottom:5px;padding-top:5px;text-align:center">
|
||||
<table cellpadding="0" cellspacing="0" role="presentation"
|
||||
style="mso-table-lspace:0;mso-table-rspace:0"
|
||||
width="100%">
|
||||
<tr>
|
||||
<td class="alignment" style="vertical-align:middle;text-align:center">
|
||||
<!--[if vml]>
|
||||
<table align="left" cellpadding="0" cellspacing="0" role="presentation"
|
||||
style="display:inline-block;padding-left:0px;padding-right:0px;mso-table-lspace: 0pt;mso-table-rspace: 0pt;">
|
||||
<![endif]-->
|
||||
<!--[if !vml]><!-->
|
||||
<table cellpadding="0"
|
||||
cellspacing="0"
|
||||
class="icons-inner" role="presentation"
|
||||
style="mso-table-lspace:0;mso-table-rspace:0;display:inline-block;margin-right:-4px;padding-left:0;padding-right:0">
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" class="row-content stack" role="presentation" style="mso-table-lspace:0;mso-table-rspace:0;color:#000;width:475px" width="505">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="column column-1" style="mso-table-lspace:0;mso-table-rspace:0;font-weight:400;text-align:left;vertical-align:top;padding-top:5px;padding-bottom:5px;border-top:0;border-right:0;border-bottom:0;border-left:0" width="100%">
|
||||
<table border="0" cellpadding="0" cellspacing="0" class="icons_block block-1" role="presentation" style="mso-table-lspace:0;mso-table-rspace:0" width="100%">
|
||||
<tr>
|
||||
<td class="pad" style="vertical-align:middle;color:#9d9d9d;font-family:inherit;font-size:15px;padding-bottom:5px;padding-top:5px;text-align:center">
|
||||
<table cellpadding="0" cellspacing="0" role="presentation" style="mso-table-lspace:0;mso-table-rspace:0" width="100%">
|
||||
<tr>
|
||||
<td class="alignment" style="vertical-align:middle;text-align:center">
|
||||
<table cellpadding="0" cellspacing="0" class="icons-inner" role="presentation" style="mso-table-lspace:0;mso-table-rspace:0;display:inline-block;margin-right:-4px;padding-left:0;padding-right:0">
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table><!-- End -->
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="footer">
|
||||
If you have any questions, contact us at <a href="mailto:support@worklenz.com" style="color:#4992f0;text-decoration:none;">support@worklenz.com</a>.<br>
|
||||
© 2025 Worklenz. All rights reserved.
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
||||
@@ -2,31 +2,30 @@
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<title></title>
|
||||
<title>Join Your Team on Worklenz</title>
|
||||
<meta content="text/html; charset=utf-8" http-equiv="Content-Type">
|
||||
<meta content="width=device-width,initial-scale=1" name="viewport">
|
||||
<style>
|
||||
* {
|
||||
box-sizing: border-box
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0
|
||||
padding: 0;
|
||||
background: linear-gradient(135deg, #f5f8ff 0%, #eaf0fb 100%);
|
||||
-webkit-text-size-adjust: none;
|
||||
text-size-adjust: none;
|
||||
font-family: 'Mada', Arial, sans-serif;
|
||||
}
|
||||
|
||||
a[x-apple-data-detectors] {
|
||||
color: inherit !important;
|
||||
text-decoration: inherit !important
|
||||
}
|
||||
|
||||
#MessageViewBody a {
|
||||
color: inherit;
|
||||
text-decoration: none
|
||||
}
|
||||
|
||||
p {
|
||||
line-height: inherit
|
||||
.main-container {
|
||||
background: #fff;
|
||||
border-radius: 18px;
|
||||
box-shadow: 0 4px 24px rgba(73, 146, 240, 0.10);
|
||||
margin: 40px auto 0 auto;
|
||||
max-width: 500px;
|
||||
padding: 0 0 20px 0;
|
||||
}
|
||||
|
||||
.padding-30 {
|
||||
@@ -42,33 +41,68 @@
|
||||
mso-hide: all;
|
||||
display: none;
|
||||
max-height: 0;
|
||||
overflow: hidden
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.modern-btn {
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
color: #fff;
|
||||
background: linear-gradient(90deg, #6249f0 0%, #4992f0d9 100%);
|
||||
border-radius: 6px;
|
||||
font-weight: 600;
|
||||
padding: 10px 32px;
|
||||
font-size: 17px;
|
||||
box-shadow: 0 2px 8px rgba(73, 146, 240, 0.15);
|
||||
transition: background 0.2s, box-shadow 0.2s;
|
||||
margin-top: 10px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.modern-btn:hover {
|
||||
background: linear-gradient(90deg, #4992f0d9 0%, #6249f0 100%);
|
||||
box-shadow: 0 4px 16px rgba(73, 146, 240, 0.18);
|
||||
}
|
||||
|
||||
.footer {
|
||||
text-align: center;
|
||||
color: #b0b8c9;
|
||||
font-size: 13px;
|
||||
margin-top: 30px;
|
||||
padding-bottom: 18px;
|
||||
}
|
||||
|
||||
@media (max-width: 525px) {
|
||||
.main-container {
|
||||
margin: 0;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
max-width: 100% !important;
|
||||
}
|
||||
|
||||
.desktop_hide table.icons-inner {
|
||||
display: inline-block !important
|
||||
display: inline-block !important;
|
||||
}
|
||||
|
||||
.icons-inner {
|
||||
text-align: center
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.icons-inner td {
|
||||
margin: 0 auto
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.row-content {
|
||||
width: 95% !important
|
||||
width: 95% !important;
|
||||
}
|
||||
|
||||
.mobile_hide {
|
||||
display: none
|
||||
display: none;
|
||||
}
|
||||
|
||||
.stack .column {
|
||||
width: 100%;
|
||||
display: block
|
||||
display: block;
|
||||
}
|
||||
|
||||
.mobile_hide {
|
||||
@@ -76,180 +110,174 @@
|
||||
max-height: 0;
|
||||
max-width: 0;
|
||||
overflow: hidden;
|
||||
font-size: 0
|
||||
font-size: 0;
|
||||
}
|
||||
|
||||
.desktop_hide,
|
||||
.desktop_hide table {
|
||||
display: table !important;
|
||||
max-height: none !important
|
||||
max-height: none !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Mada:wght@500;700&display=swap" rel="stylesheet">
|
||||
</head>
|
||||
|
||||
<body style="background-color:#fff;margin:0;padding:0;-webkit-text-size-adjust:none;text-size-adjust:none">
|
||||
<table border="0" cellpadding="0" cellspacing="0" class="nl-container" role="presentation"
|
||||
style="mso-table-lspace:0;mso-table-rspace:0; background-image:none;background-position:top left;background-size:auto;background-repeat:no-repeat"
|
||||
width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" class="row row-1" role="presentation"
|
||||
style="mso-table-lspace:0;mso-table-rspace:0" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
|
||||
<table border="0" cellpadding="0" cellspacing="0" class="image_block block-1 padding-20" role="presentation"
|
||||
style="mso-table-lspace:0;mso-table-rspace:0;margin-left: auto;margin-right: auto;padding-top: 20px;
|
||||
padding-bottom: 20px;" width="220">
|
||||
<tr>
|
||||
<td class="pad" style="width:100%;padding-right:0;padding-left:0">
|
||||
<div align="left" class="alignment" style="line-height:10px">
|
||||
<a href="www.worklenz.com" style="outline:none;width: 170px;" tabindex="-1"
|
||||
target="_blank"><img
|
||||
src="https://worklenz.s3.amazonaws.com/email-templates-assets/email-logo.png"
|
||||
style="display:block;height:auto;border:0;max-width:100%;margin-top: 10px;margin-bottom: 5px;"></a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" class="row-content stack"
|
||||
role="presentation"
|
||||
style="mso-table-lspace:0;mso-table-rspace:0;color:#000;width:475px;"
|
||||
width="475">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="column column-1"
|
||||
style="mso-table-lspace:0;mso-table-rspace:0;font-weight:400;text-align:left;vertical-align:top;padding-top:5px;padding-bottom:5px;border-top:0;border-right:0;border-bottom:0;border-left:0"
|
||||
width="100%">
|
||||
<table border="0" cellpadding="0" cellspacing="0" class="image_block block-3" role="presentation"
|
||||
style="mso-table-lspace:0;mso-table-rspace:0" width="100%">
|
||||
<tr>
|
||||
<td class="pad">
|
||||
<h1
|
||||
style="margin:0;color:rgb(66, 66, 66);font-size:26px;line-height:120%;text-align:center;direction:ltr;font-weight:700;letter-spacing:normal;margin-top:30px;margin-bottom:0;padding-top: 20px;padding-bottom: 20px;@import url('https://fonts.googleapis.com/css2?family=Mada:wght@500&display=swap');font-family: 'Mada', sans-serif;">
|
||||
<span class="tinyMce-placeholder">Join your team on Worklenz</span>
|
||||
</h1>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="pad" style="width:100%;padding-right:0;padding-left:0">
|
||||
<div align="center" class="alignment" style="line-height:10px"><img
|
||||
src="https://worklenz.s3.amazonaws.com/email-templates-assets/email-unregistered-team-member.png"
|
||||
style="display:block;height:auto;border:0;width:180px;max-width:100%;margin-top: 30px;margin-bottom: 10px;"
|
||||
width="180">
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<table border="0" cellpadding="5" cellspacing="0" class="paragraph_block block-4 padding-30"
|
||||
role="presentation" style="mso-table-lspace:0;mso-table-rspace:0;word-break:break-word"
|
||||
width="100%">
|
||||
<tr>
|
||||
<td class="pad">
|
||||
<div
|
||||
style="color:#505771;font-size:19px;@import url('https://fonts.googleapis.com/css2?family=Mada:wght@500&display=swap');font-family: 'Mada', sans-serif;font-weight:500;line-height:120%;text-align:center;direction:ltr;letter-spacing:0;mso-line-height-alt:24px">
|
||||
<p style="margin-top: 0px;margin-bottom: 2px;">Hi,</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<table border="0" cellpadding="8" cellspacing="0" class="paragraph_block block-5 padding-30"
|
||||
role="presentation" style="mso-table-lspace:0;mso-table-rspace:0;word-break:break-word"
|
||||
width="100%">
|
||||
<tr>
|
||||
<td class="pad">
|
||||
<div
|
||||
style="color:rgb(143,152,164);font-size:16px;@import url('https://fonts.googleapis.com/css2?family=Mada:wght@500&display=swap');font-family: 'Mada', sans-serif;font-weight:400;line-height:135%;text-align:center;direction:ltr;letter-spacing:0;mso-line-height-alt:18px">
|
||||
<p style="margin:0;margin-bottom:10px">You have been added to the "[VAR_TEAM_NAME]" team
|
||||
on Worklenz!</p>
|
||||
<p>Create an account in Worklenz to continue.</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<table border="0" cellpadding="10" cellspacing="0" class="button_block block-6" role="presentation"
|
||||
style="mso-table-lspace:0;mso-table-rspace:0;margin-top: 10px;margin-bottom: 30px;"
|
||||
width="100%">
|
||||
<tr>
|
||||
<td class="pad">
|
||||
<div align="center" class="alignment">
|
||||
<a href="https://[VAR_HOSTNAME]/auth/signup?email=[VAR_EMAIL]&user=[VAR_USER_ID]&team=[VAR_TEAM_ID]&name=[VAR_USER_NAME]&project=[PROJECT_ID]">
|
||||
<div
|
||||
style="text-decoration:none;display:inline-block;color:#fff;background: #6249f0;border-radius:3px;width:auto;font-weight:400;padding-top:6px;padding-bottom:7px;@import url('https://fonts.googleapis.com/css2?family=Mada:wght@500&display=swap');font-family: 'Mada', sans-serif;text-align:center;mso-border-alt:none;word-break:keep-all">
|
||||
<span
|
||||
style="padding-left:25px;padding-right:25px;font-size:16px;display:inline-block;letter-spacing:normal;"><span
|
||||
dir="ltr" style="word-break: break-word; line-height: 28px;">Join
|
||||
Worklenz</span></span>
|
||||
</div>
|
||||
</a>
|
||||
<!--[if mso]></center></v:textbox></v:roundrect><![endif]-->
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<body>
|
||||
<div class="main-container">
|
||||
<table border="0" cellpadding="0" cellspacing="0" class="nl-container" role="presentation"
|
||||
style="background:transparent;"
|
||||
width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" class="row row-1" role="presentation"
|
||||
style="mso-table-lspace:0;mso-table-rspace:0" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<table border="0" cellpadding="0" cellspacing="0" class="image_block block-1 padding-20" role="presentation"
|
||||
style="mso-table-lspace:0;mso-table-rspace:0;margin-left: auto;margin-right: auto;padding-top: 20px;
|
||||
padding-bottom: 20px;" width="220">
|
||||
<tr>
|
||||
<td class="pad" style="width:100%;padding-right:0;padding-left:0">
|
||||
<div align="left" class="alignment" style="line-height:10px">
|
||||
<a href="www.worklenz.com" style="outline:none;width: 170px;" tabindex="-1"
|
||||
target="_blank"><img
|
||||
src="https://worklenz.s3.amazonaws.com/email-templates-assets/email-logo.png"
|
||||
style="display:block;height:auto;border:0;max-width:100%;margin-top: 10px;margin-bottom: 5px;"></a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" class="row row-2" role="presentation"
|
||||
style="mso-table-lspace:0;mso-table-rspace:0"
|
||||
width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" class="row-content stack"
|
||||
role="presentation"
|
||||
style="mso-table-lspace:0;mso-table-rspace:0;color:#000;width:475px" width="505">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="column column-1"
|
||||
style="mso-table-lspace:0;mso-table-rspace:0;font-weight:400;text-align:left;vertical-align:top;padding-top:5px;padding-bottom:5px;border-top:0;border-right:0;border-bottom:0;border-left:0"
|
||||
width="100%">
|
||||
<table border="0" cellpadding="0" cellspacing="0" class="icons_block block-1" role="presentation"
|
||||
style="mso-table-lspace:0;mso-table-rspace:0" width="100%">
|
||||
<tr>
|
||||
<td class="pad"
|
||||
style="vertical-align:middle;color:#9d9d9d;font-family:inherit;font-size:15px;padding-bottom:5px;padding-top:5px;text-align:center">
|
||||
<table cellpadding="0" cellspacing="0" role="presentation"
|
||||
style="mso-table-lspace:0;mso-table-rspace:0"
|
||||
width="100%">
|
||||
<tr>
|
||||
<td class="alignment" style="vertical-align:middle;text-align:center">
|
||||
<!--[if vml]>
|
||||
<table align="left" cellpadding="0" cellspacing="0" role="presentation"
|
||||
style="display:inline-block;padding-left:0px;padding-right:0px;mso-table-lspace: 0pt;mso-table-rspace: 0pt;">
|
||||
<![endif]-->
|
||||
<!--[if !vml]><!-->
|
||||
<table cellpadding="0"
|
||||
cellspacing="0"
|
||||
class="icons-inner" role="presentation"
|
||||
style="mso-table-lspace:0;mso-table-rspace:0;display:inline-block;margin-right:-4px;padding-left:0;padding-right:0">
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table><!-- End -->
|
||||
</tr>
|
||||
</table>
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" class="row-content stack"
|
||||
role="presentation"
|
||||
style="mso-table-lspace:0;mso-table-rspace:0;color:#000;width:475px;"
|
||||
width="475">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="column column-1"
|
||||
style="mso-table-lspace:0;mso-table-rspace:0;font-weight:400;text-align:left;vertical-align:top;padding-top:5px;padding-bottom:5px;border-top:0;border-right:0;border-bottom:0;border-left:0"
|
||||
width="100%">
|
||||
<table border="0" cellpadding="0" cellspacing="0" class="image_block block-3" role="presentation"
|
||||
style="mso-table-lspace:0;mso-table-rspace:0" width="100%">
|
||||
<tr>
|
||||
<td class="pad">
|
||||
<h1
|
||||
style="margin:0;color:rgb(66, 66, 66);font-size:28px;line-height:120%;text-align:center;direction:ltr;font-weight:700;letter-spacing:0.5px;margin-top:30px;margin-bottom:0;padding-top: 20px;padding-bottom: 20px;font-family: 'Mada', Arial, sans-serif;">
|
||||
<span class="tinyMce-placeholder">Join your team on Worklenz</span>
|
||||
</h1>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="pad" style="width:100%;padding-right:0;padding-left:0">
|
||||
<div align="center" class="alignment" style="line-height:10px"><img
|
||||
src="https://worklenz.s3.amazonaws.com/email-templates-assets/email-unregistered-team-member.png"
|
||||
style="display:block;height:auto;border:0;width:180px;max-width:100%;margin-top: 30px;margin-bottom: 10px;"
|
||||
width="180">
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<table border="0" cellpadding="5" cellspacing="0" class="paragraph_block block-4 padding-30"
|
||||
role="presentation" style="mso-table-lspace:0;mso-table-rspace:0;word-break:break-word"
|
||||
width="100%">
|
||||
<tr>
|
||||
<td class="pad">
|
||||
<div
|
||||
style="color:#505771;font-size:20px;font-family: 'Mada', Arial, sans-serif;font-weight:500;line-height:130%;text-align:center;direction:ltr;letter-spacing:0.1px;mso-line-height-alt:24px">
|
||||
<p style="margin-top: 0px;margin-bottom: 2px;">Hi,</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<table border="0" cellpadding="8" cellspacing="0" class="paragraph_block block-5 padding-30"
|
||||
role="presentation" style="mso-table-lspace:0;mso-table-rspace:0;word-break:break-word"
|
||||
width="100%">
|
||||
<tr>
|
||||
<td class="pad">
|
||||
<div
|
||||
style="color:rgb(143,152,164);font-size:16px;font-family: 'Mada', Arial, sans-serif;font-weight:400;line-height:145%;text-align:center;direction:ltr;letter-spacing:0.1px;mso-line-height-alt:18px">
|
||||
<p style="margin:0;margin-bottom:10px">You have been added to the "[VAR_TEAM_NAME]" team
|
||||
on Worklenz!</p>
|
||||
<p>Create an account in Worklenz to continue.</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<table border="0" cellpadding="10" cellspacing="0" class="button_block block-6" role="presentation"
|
||||
style="mso-table-lspace:0;mso-table-rspace:0;margin-top: 10px;margin-bottom: 30px;"
|
||||
width="100%">
|
||||
<tr>
|
||||
<td class="pad">
|
||||
<div align="center" class="alignment">
|
||||
<a href="https://[VAR_HOSTNAME]/auth/signup?email=[VAR_EMAIL]&user=[VAR_USER_ID]&team=[VAR_TEAM_ID]&name=[VAR_USER_NAME]&project=[PROJECT_ID]" class="modern-btn">
|
||||
Join Worklenz
|
||||
</a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" class="row row-2" role="presentation"
|
||||
style="mso-table-lspace:0;mso-table-rspace:0"
|
||||
width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" class="row-content stack"
|
||||
role="presentation"
|
||||
style="mso-table-lspace:0;mso-table-rspace:0;color:#000;width:475px" width="505">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="column column-1"
|
||||
style="mso-table-lspace:0;mso-table-rspace:0;font-weight:400;text-align:left;vertical-align:top;padding-top:5px;padding-bottom:5px;border-top:0;border-right:0;border-bottom:0;border-left:0"
|
||||
width="100%">
|
||||
<table border="0" cellpadding="0" cellspacing="0" class="icons_block block-1" role="presentation"
|
||||
style="mso-table-lspace:0;mso-table-rspace:0" width="100%">
|
||||
<tr>
|
||||
<td class="pad"
|
||||
style="vertical-align:middle;color:#9d9d9d;font-family:inherit;font-size:15px;padding-bottom:5px;padding-top:5px;text-align:center">
|
||||
<table cellpadding="0" cellspacing="0" role="presentation"
|
||||
style="mso-table-lspace:0;mso-table-rspace:0"
|
||||
width="100%">
|
||||
<tr>
|
||||
<td class="alignment" style="vertical-align:middle;text-align:center">
|
||||
<table cellpadding="0"
|
||||
cellspacing="0"
|
||||
class="icons-inner" role="presentation"
|
||||
style="mso-table-lspace:0;mso-table-rspace:0;display:inline-block;margin-right:-4px;padding-left:0;padding-right:0">
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="footer">
|
||||
If you have any questions, contact us at <a href="mailto:support@worklenz.com" style="color:#4992f0;text-decoration:none;">support@worklenz.com</a>.<br>
|
||||
© 2025 Worklenz. All rights reserved.
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
||||
@@ -2,31 +2,30 @@
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<title></title>
|
||||
<title>Welcome to Worklenz</title>
|
||||
<meta content="text/html; charset=utf-8" http-equiv="Content-Type">
|
||||
<meta content="width=device-width,initial-scale=1" name="viewport">
|
||||
<style>
|
||||
* {
|
||||
box-sizing: border-box
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0
|
||||
padding: 0;
|
||||
background: linear-gradient(135deg, #f5f8ff 0%, #eaf0fb 100%);
|
||||
-webkit-text-size-adjust: none;
|
||||
text-size-adjust: none;
|
||||
font-family: 'Mada', Arial, sans-serif;
|
||||
}
|
||||
|
||||
a[x-apple-data-detectors] {
|
||||
color: inherit !important;
|
||||
text-decoration: inherit !important
|
||||
}
|
||||
|
||||
#MessageViewBody a {
|
||||
color: inherit;
|
||||
text-decoration: none
|
||||
}
|
||||
|
||||
p {
|
||||
line-height: inherit
|
||||
.main-container {
|
||||
background: #fff;
|
||||
border-radius: 18px;
|
||||
box-shadow: 0 4px 24px rgba(73, 146, 240, 0.10);
|
||||
margin: 40px auto 0 auto;
|
||||
max-width: 500px;
|
||||
padding: 0 0 20px 0;
|
||||
}
|
||||
|
||||
.padding-30 {
|
||||
@@ -42,33 +41,68 @@
|
||||
mso-hide: all;
|
||||
display: none;
|
||||
max-height: 0;
|
||||
overflow: hidden
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.modern-btn {
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
color: #fff;
|
||||
background: linear-gradient(90deg, #4992f0d9 0%, #3b6fd6 100%);
|
||||
border-radius: 6px;
|
||||
font-weight: 600;
|
||||
padding: 10px 32px;
|
||||
font-size: 17px;
|
||||
box-shadow: 0 2px 8px rgba(73, 146, 240, 0.15);
|
||||
transition: background 0.2s, box-shadow 0.2s;
|
||||
margin-top: 10px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.modern-btn:hover {
|
||||
background: linear-gradient(90deg, #3b6fd6 0%, #4992f0d9 100%);
|
||||
box-shadow: 0 4px 16px rgba(73, 146, 240, 0.18);
|
||||
}
|
||||
|
||||
.footer {
|
||||
text-align: center;
|
||||
color: #b0b8c9;
|
||||
font-size: 13px;
|
||||
margin-top: 30px;
|
||||
padding-bottom: 18px;
|
||||
}
|
||||
|
||||
@media (max-width: 525px) {
|
||||
.main-container {
|
||||
margin: 0;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
max-width: 100% !important;
|
||||
}
|
||||
|
||||
.desktop_hide table.icons-inner {
|
||||
display: inline-block !important
|
||||
display: inline-block !important;
|
||||
}
|
||||
|
||||
.icons-inner {
|
||||
text-align: center
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.icons-inner td {
|
||||
margin: 0 auto
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.row-content {
|
||||
width: 95% !important
|
||||
width: 95% !important;
|
||||
}
|
||||
|
||||
.mobile_hide {
|
||||
display: none
|
||||
display: none;
|
||||
}
|
||||
|
||||
.stack .column {
|
||||
width: 100%;
|
||||
display: block
|
||||
display: block;
|
||||
}
|
||||
|
||||
.mobile_hide {
|
||||
@@ -76,179 +110,173 @@
|
||||
max-height: 0;
|
||||
max-width: 0;
|
||||
overflow: hidden;
|
||||
font-size: 0
|
||||
font-size: 0;
|
||||
}
|
||||
|
||||
.desktop_hide,
|
||||
.desktop_hide table {
|
||||
display: table !important;
|
||||
max-height: none !important
|
||||
max-height: none !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Mada:wght@500;700&display=swap" rel="stylesheet">
|
||||
</head>
|
||||
|
||||
<body style="background-color:#fff;margin:0;padding:0;-webkit-text-size-adjust:none;text-size-adjust:none">
|
||||
<table border="0" cellpadding="0" cellspacing="0" class="nl-container" role="presentation"
|
||||
style="mso-table-lspace:0;mso-table-rspace:0;background-image:none;background-position:top left;background-size:auto;background-repeat:no-repeat"
|
||||
width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" class="row row-1" role="presentation"
|
||||
style="mso-table-lspace:0;mso-table-rspace:0" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
|
||||
<table border="0" cellpadding="0" cellspacing="0" class="image_block block-1 padding-20" role="presentation"
|
||||
style="mso-table-lspace:0;mso-table-rspace:0;margin-left: auto;margin-right: auto;padding-top: 20px;
|
||||
padding-bottom: 20px;" width="220">
|
||||
<tr>
|
||||
<td class="pad" style="width:100%;padding-right:0;padding-left:0">
|
||||
<div align="left" class="alignment" style="line-height:10px">
|
||||
<a href="www.worklenz.com" style="outline:none;width: 170px;" tabindex="-1"
|
||||
target="_blank"><img
|
||||
src="https://worklenz.s3.amazonaws.com/email-templates-assets/email-logo.png"
|
||||
style="display:block;height:auto;border:0;max-width:100%;margin-top: 10px;margin-bottom: 5px;"></a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" class="row-content stack"
|
||||
role="presentation"
|
||||
style="mso-table-lspace:0;mso-table-rspace:0;color:#000;width:475px;"
|
||||
width="475">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="column column-1"
|
||||
style="mso-table-lspace:0;mso-table-rspace:0;font-weight:400;text-align:left;vertical-align:top;padding-top:5px;padding-bottom:5px;border-top:0;border-right:0;border-bottom:0;border-left:0"
|
||||
width="100%">
|
||||
<table border="0" cellpadding="0" cellspacing="0" class="image_block block-3" role="presentation"
|
||||
style="mso-table-lspace:0;mso-table-rspace:0" width="100%">
|
||||
<tr>
|
||||
<td class="pad">
|
||||
<h1
|
||||
style="margin:0;color:rgb(66, 66, 66);font-size:26px;line-height:120%;text-align:center;direction:ltr;font-weight:700;letter-spacing:normal;margin-top:30px;margin-bottom:0;padding-top: 20px;padding-bottom: 20px;@import url('https://fonts.googleapis.com/css2?family=Mada:wght@500&display=swap');font-family: 'Mada', sans-serif;">
|
||||
<span class="tinyMce-placeholder">Let's get started with Worklenz.</span>
|
||||
</h1>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="pad" style="width:100%;padding-right:0;padding-left:0">
|
||||
<div align="center" class="alignment" style="line-height:10px"><img
|
||||
src="https://worklenz.s3.amazonaws.com/email-templates-assets/email-signup.png"
|
||||
style="display:block;height:auto;border:0;width:180px;max-width:100%;margin-top: 30px;margin-bottom: 10px;"
|
||||
width="180">
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<table border="0" cellpadding="5" cellspacing="0" class="paragraph_block block-4 padding-30"
|
||||
role="presentation" style="mso-table-lspace:0;mso-table-rspace:0;word-break:break-word"
|
||||
width="100%">
|
||||
<tr>
|
||||
<td class="pad">
|
||||
<div
|
||||
style="color:#505771;font-size:19px;@import url('https://fonts.googleapis.com/css2?family=Mada:wght@500&display=swap');font-family: 'Mada', sans-serif;font-weight:500;line-height:120%;text-align:center;direction:ltr;letter-spacing:0;mso-line-height-alt:24px">
|
||||
<p style="margin-top: 0px;margin-bottom: 2px;">Hi [VAR_USER_NAME],</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<table border="0" cellpadding="8" cellspacing="0" class="paragraph_block block-5 padding-30"
|
||||
role="presentation" style="mso-table-lspace:0;mso-table-rspace:0;word-break:break-word"
|
||||
width="100%">
|
||||
<tr>
|
||||
<td class="pad">
|
||||
<div
|
||||
style="color:rgb(143,152,164);font-size:16px;@import url('https://fonts.googleapis.com/css2?family=Mada:wght@500&display=swap');font-family: 'Mada', sans-serif;font-weight:400;line-height:135%;text-align:center;direction:ltr;letter-spacing:0;mso-line-height-alt:18px">
|
||||
<p style="margin:0;margin-bottom:10px">Thanks for joining Worklenz!</p>
|
||||
<p style="margin:0"> We're excited to have you on board. </p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<table border="0" cellpadding="10" cellspacing="0" class="button_block block-6" role="presentation"
|
||||
style="mso-table-lspace:0;mso-table-rspace:0;margin-top: 10px;margin-bottom: 30px;"
|
||||
width="100%">
|
||||
<tr>
|
||||
<td class="pad">
|
||||
<div align="center" class="alignment">
|
||||
<a href="https://[VAR_HOSTNAME]/auth/login">
|
||||
<div
|
||||
style="text-decoration:none;display:inline-block;color:#fff;background:#4992f0d9;border-radius:3px;width:auto;font-weight:400;padding-top:6px;padding-bottom:7px;@import url('https://fonts.googleapis.com/css2?family=Mada:wght@500&display=swap');font-family: 'Mada', sans-serif;text-align:center;mso-border-alt:none;word-break:keep-all">
|
||||
<span
|
||||
style="padding-left:25px;padding-right:25px;font-size:16px;display:inline-block;letter-spacing:normal;"><span
|
||||
dir="ltr" style="word-break: break-word; line-height: 28px;">Go to
|
||||
Worklenz</span></span>
|
||||
</div>
|
||||
</a>
|
||||
<!--[if mso]></center></v:textbox></v:roundrect><![endif]-->
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<body>
|
||||
<div class="main-container">
|
||||
<table border="0" cellpadding="0" cellspacing="0" class="nl-container" role="presentation"
|
||||
style="mso-table-lspace:0;mso-table-rspace:0;background:transparent;"
|
||||
width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" class="row row-1" role="presentation"
|
||||
style="mso-table-lspace:0;mso-table-rspace:0" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<table border="0" cellpadding="0" cellspacing="0" class="image_block block-1 padding-20" role="presentation"
|
||||
style="mso-table-lspace:0;mso-table-rspace:0;margin-left: auto;margin-right: auto;padding-top: 20px;
|
||||
padding-bottom: 20px;" width="220">
|
||||
<tr>
|
||||
<td class="pad" style="width:100%;padding-right:0;padding-left:0">
|
||||
<div align="left" class="alignment" style="line-height:10px">
|
||||
<a href="www.worklenz.com" style="outline:none;width: 170px;" tabindex="-1"
|
||||
target="_blank"><img
|
||||
src="https://worklenz.s3.amazonaws.com/email-templates-assets/email-logo.png"
|
||||
style="display:block;height:auto;border:0;max-width:100%;margin-top: 10px;margin-bottom: 5px;"></a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" class="row row-2" role="presentation"
|
||||
style="mso-table-lspace:0;mso-table-rspace:0"
|
||||
width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" class="row-content stack"
|
||||
role="presentation"
|
||||
style="mso-table-lspace:0;mso-table-rspace:0;color:#000;width:475px" width="505">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="column column-1"
|
||||
style="mso-table-lspace:0;mso-table-rspace:0;font-weight:400;text-align:left;vertical-align:top;padding-top:5px;padding-bottom:5px;border-top:0;border-right:0;border-bottom:0;border-left:0"
|
||||
width="100%">
|
||||
<table border="0" cellpadding="0" cellspacing="0" class="icons_block block-1" role="presentation"
|
||||
style="mso-table-lspace:0;mso-table-rspace:0" width="100%">
|
||||
<tr>
|
||||
<td class="pad"
|
||||
style="vertical-align:middle;color:#9d9d9d;font-family:inherit;font-size:15px;padding-bottom:5px;padding-top:5px;text-align:center">
|
||||
<table cellpadding="0" cellspacing="0" role="presentation"
|
||||
style="mso-table-lspace:0;mso-table-rspace:0"
|
||||
width="100%">
|
||||
<tr>
|
||||
<td class="alignment" style="vertical-align:middle;text-align:center">
|
||||
<!--[if vml]>
|
||||
<table align="left" cellpadding="0" cellspacing="0" role="presentation"
|
||||
style="display:inline-block;padding-left:0px;padding-right:0px;mso-table-lspace: 0pt;mso-table-rspace: 0pt;">
|
||||
<![endif]-->
|
||||
<!--[if !vml]><!-->
|
||||
<table cellpadding="0"
|
||||
cellspacing="0"
|
||||
class="icons-inner" role="presentation"
|
||||
style="mso-table-lspace:0;mso-table-rspace:0;display:inline-block;margin-right:-4px;padding-left:0;padding-right:0">
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table><!-- End -->
|
||||
</tr>
|
||||
</table>
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" class="row-content stack"
|
||||
role="presentation"
|
||||
style="mso-table-lspace:0;mso-table-rspace:0;color:#000;width:475px;"
|
||||
width="475">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="column column-1"
|
||||
style="mso-table-lspace:0;mso-table-rspace:0;font-weight:400;text-align:left;vertical-align:top;padding-top:5px;padding-bottom:5px;border-top:0;border-right:0;border-bottom:0;border-left:0"
|
||||
width="100%">
|
||||
<table border="0" cellpadding="0" cellspacing="0" class="image_block block-3" role="presentation"
|
||||
style="mso-table-lspace:0;mso-table-rspace:0" width="100%">
|
||||
<tr>
|
||||
<td class="pad">
|
||||
<h1
|
||||
style="margin:0;color:rgb(66, 66, 66);font-size:28px;line-height:120%;text-align:center;direction:ltr;font-weight:700;letter-spacing:0.5px;margin-top:30px;margin-bottom:0;padding-top: 20px;padding-bottom: 20px;font-family: 'Mada', Arial, sans-serif;">
|
||||
<span class="tinyMce-placeholder">Let's get started with Worklenz.</span>
|
||||
</h1>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="pad" style="width:100%;padding-right:0;padding-left:0">
|
||||
<div align="center" class="alignment" style="line-height:10px"><img
|
||||
src="https://worklenz.s3.amazonaws.com/email-templates-assets/email-signup.png"
|
||||
style="display:block;height:auto;border:0;width:180px;max-width:100%;margin-top: 30px;margin-bottom: 10px;"
|
||||
width="180">
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<table border="0" cellpadding="5" cellspacing="0" class="paragraph_block block-4 padding-30"
|
||||
role="presentation" style="mso-table-lspace:0;mso-table-rspace:0;word-break:break-word"
|
||||
width="100%">
|
||||
<tr>
|
||||
<td class="pad">
|
||||
<div
|
||||
style="color:#505771;font-size:20px;font-family: 'Mada', Arial, sans-serif;font-weight:500;line-height:130%;text-align:center;direction:ltr;letter-spacing:0.1px;mso-line-height-alt:24px">
|
||||
<p style="margin-top: 0px;margin-bottom: 2px;">Hi [VAR_USER_NAME],</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<table border="0" cellpadding="8" cellspacing="0" class="paragraph_block block-5 padding-30"
|
||||
role="presentation" style="mso-table-lspace:0;mso-table-rspace:0;word-break:break-word"
|
||||
width="100%">
|
||||
<tr>
|
||||
<td class="pad">
|
||||
<div
|
||||
style="color:rgb(143,152,164);font-size:16px;font-family: 'Mada', Arial, sans-serif;font-weight:400;line-height:145%;text-align:center;direction:ltr;letter-spacing:0.1px;mso-line-height-alt:18px">
|
||||
<p style="margin:0;margin-bottom:10px">Thanks for joining Worklenz!</p>
|
||||
<p style="margin:0"> We're excited to have you on board. </p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<table border="0" cellpadding="10" cellspacing="0" class="button_block block-6" role="presentation"
|
||||
style="mso-table-lspace:0;mso-table-rspace:0;margin-top: 10px;margin-bottom: 30px;"
|
||||
width="100%">
|
||||
<tr>
|
||||
<td class="pad">
|
||||
<div align="center" class="alignment">
|
||||
<a href="https://[VAR_HOSTNAME]/auth/login" class="modern-btn">
|
||||
Go to Worklenz
|
||||
</a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" class="row row-2" role="presentation"
|
||||
style="mso-table-lspace:0;mso-table-rspace:0"
|
||||
width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" class="row-content stack"
|
||||
role="presentation"
|
||||
style="mso-table-lspace:0;mso-table-rspace:0;color:#000;width:475px" width="505">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="column column-1"
|
||||
style="mso-table-lspace:0;mso-table-rspace:0;font-weight:400;text-align:left;vertical-align:top;padding-top:5px;padding-bottom:5px;border-top:0;border-right:0;border-bottom:0;border-left:0"
|
||||
width="100%">
|
||||
<table border="0" cellpadding="0" cellspacing="0" class="icons_block block-1" role="presentation"
|
||||
style="mso-table-lspace:0;mso-table-rspace:0" width="100%">
|
||||
<tr>
|
||||
<td class="pad"
|
||||
style="vertical-align:middle;color:#9d9d9d;font-family:inherit;font-size:15px;padding-bottom:5px;padding-top:5px;text-align:center">
|
||||
<table cellpadding="0" cellspacing="0" role="presentation"
|
||||
style="mso-table-lspace:0;mso-table-rspace:0"
|
||||
width="100%">
|
||||
<tr>
|
||||
<td class="alignment" style="vertical-align:middle;text-align:center">
|
||||
<table cellpadding="0"
|
||||
cellspacing="0"
|
||||
class="icons-inner" role="presentation"
|
||||
style="mso-table-lspace:0;mso-table-rspace:0;display:inline-block;margin-right:-4px;padding-left:0;padding-right:0">
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="footer">
|
||||
If you have any questions, contact us at <a href="mailto:support@worklenz.com" style="color:#4992f0;text-decoration:none;">support@worklenz.com</a>.<br>
|
||||
© 2025 Worklenz. All rights reserved.
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user