This commit is contained in:
chamikaJ
2025-04-17 18:28:54 +05:30
parent f583291d8a
commit 8825b0410a
2837 changed files with 241385 additions and 127578 deletions

View File

@@ -5,8 +5,19 @@ import db from "../config/db";
import {ServerResponse} from "../models/server-response";
import WorklenzControllerBase from "./worklenz-controller-base";
import HandleExceptions from "../decorators/handle-exceptions";
import {getColor} from "../shared/utils";
import {calculateMonthDays, getColor, 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";
import {
addModifier,
cancelSubscription,
changePlan,
generatePayLinkRequest,
pauseOrResumeSubscription,
updateUsers
} from "../shared/paddle-requests";
import {statusExclude} from "../shared/constants";
import {NotificationsService} from "../services/notifications/notifications.service";
import {SocketEvents} from "../socket.io/events";
import {IO} from "../shared/io";
@@ -262,6 +273,384 @@ export default class AdminCenterController extends WorklenzControllerBase {
return res.status(200).send(new ServerResponse(true, [], "Team updated successfully"));
}
@HandleExceptions()
public static async getBillingInfo(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
const q = `SELECT get_billing_info($1) AS billing_info;`;
const result = await db.query(q, [req.user?.owner_id]);
const [data] = result.rows;
const validTillDate = moment(data.billing_info.trial_expire_date);
const daysDifference = validTillDate.diff(moment(), "days");
const dateString = calculateMonthDays(moment().format("YYYY-MM-DD"), data.billing_info.trial_expire_date);
data.billing_info.expire_date_string = dateString;
if (daysDifference < 0) {
data.billing_info.expire_date_string = `Your trial plan expired ${dateString} ago`;
} else if (daysDifference === 0 && daysDifference < 7) {
data.billing_info.expire_date_string = `Your trial plan expires today`;
} else {
data.billing_info.expire_date_string = `Your trial plan expires in ${dateString}.`;
}
if (data.billing_info.billing_type === "year") data.billing_info.unit_price_per_month = data.billing_info.unit_price / 12;
const teamMemberData = await getTeamMemberCount(req.user?.owner_id ?? "");
const subscriptionData = await checkTeamSubscriptionStatus(req.user?.team_id ?? "");
data.billing_info.total_used = teamMemberData.user_count;
data.billing_info.total_seats = subscriptionData.quantity;
return res.status(200).send(new ServerResponse(true, data.billing_info));
}
@HandleExceptions()
public static async getBillingTransactions(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
const q = `SELECT subscription_payment_id,
event_time::date,
(next_bill_date::DATE - INTERVAL '1 day')::DATE AS next_bill_date,
currency,
receipt_url,
payment_method,
status,
payment_status
FROM licensing_payment_details
WHERE user_id = $1
ORDER BY created_at DESC;`;
const result = await db.query(q, [req.user?.owner_id]);
return res.status(200).send(new ServerResponse(true, result.rows));
}
@HandleExceptions()
public static async getBillingCharges(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
const q = `SELECT (SELECT name FROM licensing_pricing_plans lpp WHERE id = lus.plan_id),
unit_price::numeric,
currency,
status,
quantity,
unit_price::numeric * quantity AS amount,
(SELECT event_time
FROM licensing_payment_details lpd
WHERE lpd.user_id = lus.user_id
ORDER BY created_at DESC
LIMIT 1)::DATE AS start_date,
(next_bill_date::DATE - INTERVAL '1 day')::DATE AS end_date
FROM licensing_user_subscriptions lus
WHERE user_id = $1;`;
const result = await db.query(q, [req.user?.owner_id]);
const countQ = `SELECT subscription_id
FROM licensing_user_subscription_modifiers
WHERE subscription_id = (SELECT subscription_id
FROM licensing_user_subscriptions
WHERE user_id = $1
AND status != 'deleted'
LIMIT 1)::INT;`;
const countResult = await db.query(countQ, [req.user?.owner_id]);
return res.status(200).send(new ServerResponse(true, {plan_charges: result.rows, modifiers: countResult.rows}));
}
@HandleExceptions()
public static async getBillingModifiers(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
const q = `SELECT created_at
FROM licensing_user_subscription_modifiers
WHERE subscription_id = (SELECT subscription_id
FROM licensing_user_subscriptions
WHERE user_id = $1
AND status != 'deleted'
LIMIT 1)::INT;`;
const result = await db.query(q, [req.user?.owner_id]);
return res.status(200).send(new ServerResponse(true, result.rows));
}
@HandleExceptions()
public static async getBillingConfiguration(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
const q = `SELECT name,
email,
organization_name AS company_name,
contact_number AS phone,
address_line_1,
address_line_2,
city,
state,
postal_code,
country
FROM organizations
LEFT JOIN users u ON organizations.user_id = u.id
WHERE u.id = $1;`;
const result = await db.query(q, [req.user?.owner_id]);
const [data] = result.rows;
return res.status(200).send(new ServerResponse(true, data));
}
@HandleExceptions()
public static async updateBillingConfiguration(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
const {company_name, phone, address_line_1, address_line_2, city, state, postal_code, country} = req.body;
const q = `UPDATE organizations
SET organization_name = $1,
contact_number = $2,
address_line_1 = $3,
address_line_2 = $4,
city = $5,
state = $6,
postal_code = $7,
country = $8
WHERE user_id = $9;`;
const result = await db.query(q, [company_name, phone, address_line_1, address_line_2, city, state, postal_code, country, req.user?.owner_id]);
const [data] = result.rows;
return res.status(200).send(new ServerResponse(true, data, "Configuration Updated"));
}
@HandleExceptions()
public static async upgradePlan(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
const {plan} = req.query;
const obj = await getTeamMemberCount(req.user?.owner_id ?? "");
const axiosResponse = await generatePayLinkRequest(obj, plan as string, req.user?.owner_id, req.user?.id);
return res.status(200).send(new ServerResponse(true, axiosResponse.body));
}
@HandleExceptions()
public static async getPlans(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
const q = `SELECT
ls.default_monthly_plan AS monthly_plan_id,
lp_monthly.name AS monthly_plan_name,
ls.default_annual_plan AS annual_plan_id,
lp_monthly.recurring_price AS monthly_price,
lp_annual.name AS annual_plan_name,
lp_annual.recurring_price AS annual_price,
ls.team_member_limit,
ls.projects_limit,
ls.free_tier_storage
FROM
licensing_settings ls
JOIN
licensing_pricing_plans lp_monthly ON ls.default_monthly_plan = lp_monthly.id
JOIN
licensing_pricing_plans lp_annual ON ls.default_annual_plan = lp_annual.id;`;
const result = await db.query(q, []);
const [data] = result.rows;
const obj = await getTeamMemberCount(req.user?.owner_id ?? "");
data.team_member_limit = data.team_member_limit === 0 ? "Unlimited" : data.team_member_limit;
data.projects_limit = data.projects_limit === 0 ? "Unlimited" : data.projects_limit;
data.free_tier_storage = `${data.free_tier_storage}MB`;
data.current_user_count = obj.user_count;
data.annual_price = (data.annual_price / 12).toFixed(2);
return res.status(200).send(new ServerResponse(true, data));
}
@HandleExceptions()
public static async purchaseStorage(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
const q = `SELECT subscription_id
FROM licensing_user_subscriptions lus
WHERE user_id = $1;`;
const result = await db.query(q, [req.user?.owner_id]);
const [data] = result.rows;
await addModifier(data.subscription_id);
return res.status(200).send(new ServerResponse(true, data));
}
@HandleExceptions()
public static async changePlan(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
const {plan} = req.query;
const q = `SELECT subscription_id
FROM licensing_user_subscriptions lus
WHERE user_id = $1;`;
const result = await db.query(q, [req.user?.owner_id]);
const [data] = result.rows;
const axiosResponse = await changePlan(plan as string, data.subscription_id);
return res.status(200).send(new ServerResponse(true, axiosResponse.body));
}
@HandleExceptions()
public static async cancelPlan(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
if (!req.user?.owner_id) return res.status(200).send(new ServerResponse(false, "Invalid Request."));
const q = `SELECT subscription_id
FROM licensing_user_subscriptions lus
WHERE user_id = $1;`;
const result = await db.query(q, [req.user?.owner_id]);
const [data] = result.rows;
const axiosResponse = await cancelSubscription(data.subscription_id, req.user?.owner_id);
return res.status(200).send(new ServerResponse(true, axiosResponse.body));
}
@HandleExceptions()
public static async pauseSubscription(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
if (!req.user?.owner_id) return res.status(200).send(new ServerResponse(false, "Invalid Request."));
const q = `SELECT subscription_id
FROM licensing_user_subscriptions lus
WHERE user_id = $1;`;
const result = await db.query(q, [req.user?.owner_id]);
const [data] = result.rows;
const axiosResponse = await pauseOrResumeSubscription(data.subscription_id, req.user?.owner_id, true);
return res.status(200).send(new ServerResponse(true, axiosResponse.body));
}
@HandleExceptions()
public static async resumeSubscription(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
if (!req.user?.owner_id) return res.status(200).send(new ServerResponse(false, "Invalid Request."));
const q = `SELECT subscription_id
FROM licensing_user_subscriptions lus
WHERE user_id = $1;`;
const result = await db.query(q, [req.user?.owner_id]);
const [data] = result.rows;
const axiosResponse = await pauseOrResumeSubscription(data.subscription_id, req.user?.owner_id, false);
return res.status(200).send(new ServerResponse(true, axiosResponse.body));
}
@HandleExceptions()
public static async getBillingStorageInfo(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
const q = `SELECT trial_in_progress,
trial_expire_date,
ud.storage,
(SELECT name AS plan_name FROM licensing_pricing_plans WHERE id = lus.plan_id),
(SELECT default_trial_storage FROM licensing_settings),
(SELECT storage_addon_size FROM licensing_settings),
(SELECT storage_addon_price FROM licensing_settings)
FROM organizations ud
LEFT JOIN users u ON ud.user_id = u.id
LEFT JOIN licensing_user_subscriptions lus ON u.id = lus.user_id
WHERE ud.user_id = $1;`;
const result = await db.query(q, [req.user?.owner_id]);
const [data] = result.rows;
return res.status(200).send(new ServerResponse(true, data));
}
@HandleExceptions()
public static async getAccountStorage(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
const teamsQ = `SELECT id
FROM teams
WHERE user_id = $1;`;
const teamsResponse = await db.query(teamsQ, [req.user?.owner_id]);
const storageQ = `SELECT storage
FROM organizations
WHERE user_id = $1;`;
const result = await db.query(storageQ, [req.user?.owner_id]);
const [data] = result.rows;
const storage: any = {};
storage.used = 0;
storage.total = data.storage;
for (const team of teamsResponse.rows) {
storage.used += await calculateStorage(team.id);
}
storage.remaining = (storage.total * 1024 * 1024 * 1024) - storage.used;
storage.used_percent = Math.ceil((storage.used / (storage.total * 1024 * 1024 * 1024)) * 10000) / 100;
return res.status(200).send(new ServerResponse(true, storage));
}
@HandleExceptions()
public static async getCountries(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
const q = `SELECT id, name, code
FROM countries
ORDER BY name;`;
const result = await db.query(q, []);
return res.status(200).send(new ServerResponse(true, result.rows || []));
}
@HandleExceptions()
public static async switchToFreePlan(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
const { id: teamId } = req.params;
const limits = await getFreePlanSettings();
const ownerId = await getOwnerIdByTeam(teamId);
if (limits && ownerId) {
if (parseInt(limits.team_member_limit) !== 0) {
const teamMemberCount = await getTeamMemberCount(ownerId);
if (parseInt(teamMemberCount) > parseInt(limits.team_member_limit)) {
return res.status(200).send(new ServerResponse(false, [], `Sorry, the free plan cannot have more than ${limits.team_member_limit} members.`));
}
}
const projectsCount = await getCurrentProjectsCount(ownerId);
if (parseInt(projectsCount) > parseInt(limits.projects_limit)) {
return res.status(200).send(new ServerResponse(false, [], `Sorry, the free plan cannot have more than ${limits.projects_limit} projects.`));
}
const usedStorage = await getUsedStorage(ownerId);
if (parseInt(usedStorage) > megabytesToBytes(parseInt(limits.free_tier_storage))) {
return res.status(200).send(new ServerResponse(false, [], `Sorry, the free plan cannot exceed ${limits.free_tier_storage}MB of storage.`));
}
const update_q = `UPDATE organizations
SET license_type_id = (SELECT id FROM sys_license_types WHERE key = 'FREE'),
trial_in_progress = FALSE,
subscription_status = 'free',
storage = (SELECT free_tier_storage FROM licensing_settings)
WHERE user_id = $1;`;
await db.query(update_q, [ownerId]);
return res.status(200).send(new ServerResponse(true, [], "Your plan has been successfully switched to the Free Plan."));
}
return res.status(200).send(new ServerResponse(false, [], "Failed to switch to the Free Plan. Please try again later."));
}
@HandleExceptions()
public static async redeem(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
const { code } = req.body;
const q = `SELECT * FROM licensing_coupon_codes WHERE coupon_code = $1 AND is_redeemed IS FALSE AND is_refunded IS FALSE;`;
const result = await db.query(q, [code]);
const [data] = result.rows;
if (!result.rows.length)
return res.status(200).send(new ServerResponse(false, [], "Redeem Code verification Failed! Please try again."));
const checkQ = `SELECT sum(team_members_limit) AS team_member_total FROM licensing_coupon_codes WHERE redeemed_by = $1 AND is_redeemed IS TRUE;`;
const checkResult = await db.query(checkQ, [req.user?.owner_id]);
const [total] = checkResult.rows;
if (parseInt(total.team_member_total) > 50)
return res.status(200).send(new ServerResponse(false, [], "Maximum number of codes redeemed!"));
const updateQ = `UPDATE licensing_coupon_codes
SET is_redeemed = TRUE, redeemed_at = CURRENT_TIMESTAMP,
redeemed_by = $1
WHERE id = $2;`;
await db.query(updateQ, [req.user?.owner_id, data.id]);
const updateQ2 = `UPDATE organizations
SET subscription_status = 'life_time_deal',
trial_in_progress = FALSE,
storage = (SELECT sum(storage_limit) FROM licensing_coupon_codes WHERE redeemed_by = $1),
license_type_id = (SELECT id FROM sys_license_types WHERE key = 'LIFE_TIME_DEAL')
WHERE user_id = $1;`;
await db.query(updateQ2, [req.user?.owner_id]);
return res.status(200).send(new ServerResponse(true, [], "Code redeemed successfully!"));
}
@HandleExceptions()
public static async deleteTeam(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
const {id} = req.params;
@@ -284,6 +673,11 @@ export default class AdminCenterController extends WorklenzControllerBase {
if (!id || !teamId) return res.status(200).send(new ServerResponse(false, "Required fields are missing."));
// check subscription status
const subscriptionData = await checkTeamSubscriptionStatus(teamId);
if (statusExclude.includes(subscriptionData.subscription_status)) {
return res.status(200).send(new ServerResponse(false, "Please check your subscription status."));
}
const q = `SELECT remove_team_member($1, $2, $3) AS member;`;
const result = await db.query(q, [id, req.user?.id, teamId]);
@@ -291,6 +685,22 @@ export default class AdminCenterController extends WorklenzControllerBase {
const message = `You have been removed from <b>${req.user?.team_name}</b> by <b>${req.user?.name}</b>`;
// if (subscriptionData.status === "trialing") break;
if (!subscriptionData.is_credit && !subscriptionData.is_custom) {
if (subscriptionData.subscription_status === "active" && subscriptionData.quantity > 0) {
const obj = await getActiveTeamMemberCount(req.user?.owner_id ?? "");
const userActiveInOtherTeams = await this.checkIfUserActiveInOtherTeams(req.user?.owner_id as string, req.query?.email as string);
if (!userActiveInOtherTeams) {
const response = await updateUsers(subscriptionData.subscription_id, obj.user_count);
if (!response.body.subscription_id) return res.status(200).send(new ServerResponse(false, response.message || "Please check your subscription."));
}
}
}
NotificationsService.sendNotification({
receiver_socket_id: data.socket_id,
message,
@@ -305,5 +715,49 @@ export default class AdminCenterController extends WorklenzControllerBase {
return res.status(200).send(new ServerResponse(true, result.rows));
}
@HandleExceptions()
public static async getFreePlanLimits(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
const limits = await getFreePlanSettings();
return res.status(200).send(new ServerResponse(true, limits || {}));
}
@HandleExceptions()
public static async getOrganizationProjects(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
const { searchQuery, size, offset } = this.toPaginationOptions(req.query, ["p.name"]);
const countQ = `SELECT COUNT(*) AS total
FROM projects p
JOIN teams t ON p.team_id = t.id
JOIN organizations o ON t.organization_id = o.id
WHERE o.user_id = $1;`;
const countResult = await db.query(countQ, [req.user?.owner_id]);
// Query to get the project data
const dataQ = `SELECT p.id,
p.name,
t.name AS team_name,
p.created_at,
pm.member_count
FROM projects p
JOIN teams t ON p.team_id = t.id
JOIN organizations o ON t.organization_id = o.id
LEFT JOIN (
SELECT project_id, COUNT(*) AS member_count
FROM project_members
GROUP BY project_id
) pm ON p.id = pm.project_id
WHERE o.user_id = $1 ${searchQuery}
ORDER BY p.name
OFFSET $2 LIMIT $3;`;
const result = await db.query(dataQ, [req.user?.owner_id, offset, size]);
const response = {
total: countResult.rows[0]?.total ?? 0,
data: result.rows ?? []
};
return res.status(200).send(new ServerResponse(true, response));
}
}

View File

@@ -2,7 +2,8 @@ import { IWorkLenzRequest } from "../interfaces/worklenz-request";
import { IWorkLenzResponse } from "../interfaces/worklenz-response";
import db from "../config/db";
import { humanFileSize, log_error, smallId } from "../shared/utils";
import { humanFileSize, smallId } from "../shared/utils";
import { getStorageUrl } from "../shared/constants";
import { ServerResponse } from "../models/server-response";
import {
createPresignedUrlWithClient,
@@ -12,16 +13,10 @@ import {
getRootDir,
uploadBase64,
uploadBuffer
} from "../shared/s3";
} from "../shared/storage";
import WorklenzControllerBase from "./worklenz-controller-base";
import HandleExceptions from "../decorators/handle-exceptions";
const {S3_URL} = process.env;
if (!S3_URL) {
log_error("Invalid S3_URL. Please check .env file.");
}
export default class AttachmentController extends WorklenzControllerBase {
@HandleExceptions()
@@ -42,7 +37,7 @@ export default class AttachmentController extends WorklenzControllerBase {
req.user?.id,
size,
type,
`${S3_URL}/${getRootDir()}`
`${getStorageUrl()}/${getRootDir()}`
]);
const [data] = result.rows;
@@ -86,7 +81,7 @@ export default class AttachmentController extends WorklenzControllerBase {
FROM task_attachments
WHERE task_id = $1;
`;
const result = await db.query(q, [req.params.id, `${S3_URL}/${getRootDir()}`]);
const result = await db.query(q, [req.params.id, `${getStorageUrl()}/${getRootDir()}`]);
for (const item of result.rows)
item.size = humanFileSize(item.size);
@@ -121,7 +116,7 @@ export default class AttachmentController extends WorklenzControllerBase {
LEFT JOIN tasks t ON task_attachments.task_id = t.id
WHERE task_attachments.project_id = $1) rec;
`;
const result = await db.query(q, [req.params.id, `${S3_URL}/${getRootDir()}`, size, offset]);
const result = await db.query(q, [req.params.id, `${getStorageUrl()}/${getRootDir()}`, size, offset]);
const [data] = result.rows;
for (const item of data?.attachments.data || [])
@@ -135,26 +130,29 @@ export default class AttachmentController extends WorklenzControllerBase {
const q = `DELETE
FROM task_attachments
WHERE id = $1
RETURNING CONCAT($2::TEXT, '/', team_id, '/', project_id, '/', id, '.', type) AS key;`;
const result = await db.query(q, [req.params.id, getRootDir()]);
RETURNING team_id, project_id, id, type;`;
const result = await db.query(q, [req.params.id]);
const [data] = result.rows;
if (data?.key)
void deleteObject(data.key);
if (data) {
const key = getKey(data.team_id, data.project_id, data.id, data.type);
void deleteObject(key);
}
return res.status(200).send(new ServerResponse(true, result.rows));
}
@HandleExceptions()
public static async download(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
const q = `SELECT CONCAT($2::TEXT, '/', team_id, '/', project_id, '/', id, '.', type) AS key
const q = `SELECT team_id, project_id, id, type
FROM task_attachments
WHERE id = $1;`;
const result = await db.query(q, [req.query.id, getRootDir()]);
const result = await db.query(q, [req.query.id]);
const [data] = result.rows;
if (data?.key) {
const url = await createPresignedUrlWithClient(data.key, req.query.file as string);
if (data) {
const key = getKey(data.team_id, data.project_id, data.id, data.type);
const url = await createPresignedUrlWithClient(key, req.query.file as string);
return res.status(200).send(new ServerResponse(true, url));
}

View File

@@ -12,6 +12,9 @@ import WorklenzControllerBase from "./worklenz-controller-base";
import HandleExceptions from "../decorators/handle-exceptions";
import {PasswordStrengthChecker} from "../shared/password-strength-check";
import FileConstants from "../shared/file-constants";
import axios from "axios";
import {log_error} from "../shared/utils";
import {DEFAULT_ERROR_MESSAGE} from "../shared/constants";
export default class AuthController extends WorklenzControllerBase {
/** This just send ok response to the client when the request came here through the sign-up-validator */
@@ -42,11 +45,20 @@ export default class AuthController extends WorklenzControllerBase {
}
public static logout(req: IWorkLenzRequest, res: IWorkLenzResponse) {
req.logout(() => true);
req.session.destroy(() => {
res.redirect("/");
req.logout((err) => {
if (err) {
console.error("Logout error:", err);
return res.status(500).send(new AuthResponse(null, true, {}, "Logout failed", null));
}
req.session.destroy((destroyErr) => {
if (destroyErr) {
console.error("Session destroy error:", destroyErr);
}
res.status(200).send(new AuthResponse(null, req.isAuthenticated(), {}, null, null));
});
});
}
}
private static async destroyOtherSessions(userId: string, sessionId: string) {
try {
@@ -138,4 +150,25 @@ export default class AuthController extends WorklenzControllerBase {
}
return res.status(200).send(new ServerResponse(false, null, "Invalid Request. Please try again."));
}
@HandleExceptions({logWithError: "body"})
public static async verifyCaptcha(req: IWorkLenzRequest, res: IWorkLenzResponse) {
const {token} = req.body;
const secretKey = process.env.GOOGLE_CAPTCHA_SECRET_KEY;
try {
const response = await axios.post(
`https://www.google.com/recaptcha/api/siteverify?secret=${secretKey}&response=${token}`
);
const {success, score} = response.data;
if (success && score > 0.5) {
return res.status(200).send(new ServerResponse(true, null, null));
}
return res.status(400).send(new ServerResponse(false, null, "Please try again later.").withTitle("Error"));
} catch (error) {
log_error(error);
res.status(500).send(new ServerResponse(false, null, DEFAULT_ERROR_MESSAGE));
}
}
}

View File

@@ -0,0 +1,288 @@
import { IWorkLenzRequest } from "../interfaces/worklenz-request";
import { IWorkLenzResponse } from "../interfaces/worklenz-response";
import db from "../config/db";
import { ServerResponse } from "../models/server-response";
import WorklenzControllerBase from "./worklenz-controller-base";
import HandleExceptions from "../decorators/handle-exceptions";
import { getTeamMemberCount } from "../shared/paddle-utils";
import { generatePayLinkRequest, updateUsers } from "../shared/paddle-requests";
import CryptoJS from "crypto-js";
import moment from "moment";
import axios from "axios";
import crypto from "crypto";
import fs from "fs";
import path from "path";
import { log_error } from "../shared/utils";
import { sendEmail } from "../shared/email";
export default class BillingController extends WorklenzControllerBase {
public static async getInitialCharge(count: number) {
if (!count) throw new Error("No selected plan detected.");
const baseRate = 4990;
const firstTier = 15;
const secondTierEnd = 200;
if (count <= firstTier) {
return baseRate;
} else if (count <= secondTierEnd) {
return baseRate + (count - firstTier) * 300;
}
return baseRate + (secondTierEnd - firstTier) * 300 + (count - secondTierEnd) * 200;
}
public static async getBillingMonth() {
const startDate = moment().format("YYYYMMDD");
const endDate = moment().add(1, "month").subtract(1, "day").format("YYYYMMDD");
return `${startDate} - ${endDate}`;
}
public static async chargeInitialPayment(signature: string, data: any) {
const config = {
method: "post",
maxBodyLength: Infinity,
url: process.env.DP_URL,
headers: {
"Content-Type": "application/json",
"Signature": signature,
"x-api-key": process.env.DP_API_KEY
},
data
};
axios.request(config)
.then((response) => {
console.log(JSON.stringify(response.data));
})
.catch((error) => {
console.log(error);
});
}
public static async saveLocalTransaction(signature: string, data: any) {
try {
const q = `INSERT INTO transactions (status, transaction_id, transaction_status, description, date_time, reference, amount, card_number)
VALUES ($1, $2, $3);`;
const result = await db.query(q, []);
} catch (error) {
log_error(error);
}
}
@HandleExceptions()
public static async upgradeToPaidPlan(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
const { plan, seatCount } = req.query;
const teamMemberData = await getTeamMemberCount(req.user?.owner_id ?? "");
teamMemberData.user_count = seatCount as string;
const axiosResponse = await generatePayLinkRequest(teamMemberData, plan as string, req.user?.owner_id, req.user?.id);
return res.status(200).send(new ServerResponse(true, axiosResponse.body));
}
@HandleExceptions()
public static async addMoreSeats(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
const { seatCount } = req.body;
const q = `SELECT subscription_id
FROM licensing_user_subscriptions lus
WHERE user_id = $1;`;
const result = await db.query(q, [req.user?.owner_id]);
const [data] = result.rows;
const response = await updateUsers(data.subscription_id, seatCount);
if (!response.body.subscription_id) {
return res.status(200).send(new ServerResponse(false, null, response.message || "Please check your subscription."));
}
return res.status(200).send(new ServerResponse(true, null, "Your purchase has been successfully completed!").withTitle("Done"));
}
@HandleExceptions()
public static async getDirectPayObject(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
const { seatCount } = req.query;
if (!seatCount) return res.status(200).send(new ServerResponse(false, null));
const email = req.user?.email;
const name = req.user?.name;
const amount = await this.getInitialCharge(parseInt(seatCount as string));
const uniqueTimestamp = moment().format("YYYYMMDDHHmmss");
const billingMonth = await this.getBillingMonth();
const { DP_MERCHANT_ID, DP_SECRET_KEY, DP_STAGE } = process.env;
const payload = {
merchant_id: DP_MERCHANT_ID,
amount: 10,
type: "RECURRING",
order_id: `WORKLENZ_${email}_${uniqueTimestamp}`,
currency: "LKR",
return_url: null,
response_url: null,
first_name: name,
last_name: null,
phone: null,
email,
description: `${name} (${email})`,
page_type: "IN_APP",
logo: "https://app.worklenz.com/assets/icons/icon-96x96.png",
start_date: moment().format("YYYY-MM-DD"),
do_initial_payment: 1,
interval: 1,
};
const encodePayload = CryptoJS.enc.Base64.stringify(CryptoJS.enc.Utf8.parse(JSON.stringify(payload)));
const signature = CryptoJS.HmacSHA256(encodePayload, DP_SECRET_KEY as string);
return res.status(200).send(new ServerResponse(true, { signature: signature.toString(CryptoJS.enc.Hex), dataString: encodePayload, stage: DP_STAGE }));
}
@HandleExceptions()
public static async saveTransactionData(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
const { status, card, transaction, seatCount } = req.body;
const { DP_MERCHANT_ID, DP_STAGE } = process.env;
const email = req.user?.email;
const amount = await this.getInitialCharge(parseInt(seatCount as string));
const uniqueTimestamp = moment().format("YYYYMMDDHHmmss");
const billingMonth = await this.getBillingMonth();
const values = [
status,
card?.id,
card?.number,
card?.brand,
card?.type,
card?.issuer,
card?.expiry?.year,
card?.expiry?.month,
card?.walletId,
transaction?.id,
transaction?.status,
transaction?.amount || 0,
transaction?.currency || null,
transaction?.channel || null,
transaction?.dateTime || null,
transaction?.message || null,
transaction?.description || null,
req.user?.id,
req.user?.owner_id,
];
const q = `INSERT INTO licensing_lkr_payments (
status, card_id, card_number, card_brand, card_type, card_issuer,
card_expiry_year, card_expiry_month, wallet_id,
transaction_id, transaction_status, transaction_amount,
transaction_currency, transaction_channel, transaction_datetime,
transaction_message, transaction_description, user_id, owner_id
)
VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19
);`;
await db.query(q, values);
if (transaction.status === "SUCCESS") {
const payload = {
"merchantId": DP_MERCHANT_ID,
"reference": `WORKLENZ_${email}_${uniqueTimestamp}`,
"type": "CARD_PAY",
"cardId": card.id,
"refCode": req.user?.id,
amount,
"currency": "LKR"
};
const dataString = Object.values(payload).join("");
const { DP_STAGE } = process.env;
const pemFile = DP_STAGE === "PROD" ? "src/keys/PRIVATE_KEY_PROD.pem" : `src/keys/PRIVATE_KEY_DEV.pem`;
const privateKeyTest = fs.readFileSync(path.resolve(pemFile), "utf8");
const sign = crypto.createSign("SHA256");
sign.update(dataString);
sign.end();
const signature = sign.sign(privateKeyTest);
const byteArray = new Uint8Array(signature);
let byteString = "";
for (let i = 0; i < byteArray.byteLength; i++) {
byteString += String.fromCharCode(byteArray[i]);
}
const base64Signature = btoa(byteString);
this.chargeInitialPayment(base64Signature, payload);
}
return res.status(200).send(new ServerResponse(true, null, "Your purchase has been successfully completed!").withTitle("Done"));
}
@HandleExceptions()
public static async getCardList(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
const payload = {
"merchantId": "RT02300",
"reference": "1234",
"type": "LIST_CARD"
};
const { DP_STAGE } = process.env;
const dataString = `RT023001234LIST_CARD`;
const pemFile = DP_STAGE === "PROD" ? "src/keys/PRIVATE_KEY_PROD.pem" : `src/keys/PRIVATE_KEY_DEV.pem`;
const privateKeyTest = fs.readFileSync(path.resolve(pemFile), "utf8");
const sign = crypto.createSign("SHA256");
sign.update(dataString);
sign.end();
const signature = sign.sign(privateKeyTest);
const byteArray = new Uint8Array(signature);
let byteString = "";
for (let i = 0; i < byteArray.byteLength; i++) {
byteString += String.fromCharCode(byteArray[i]);
}
const base64Signature = btoa(byteString);
// const signature = CryptoJS.HmacSHA256(dataString, DP_SECRET_KEY as string).toString(CryptoJS.enc.Hex);
return res.status(200).send(new ServerResponse(true, { signature: base64Signature, dataString }));
}
@HandleExceptions()
public static async contactUs(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
const { contactNo } = req.query;
if (!contactNo) {
return res.status(200).send(new ServerResponse(false, null, "Contact number is required!"));
}
const html = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Worklenz Local Billing - Contact Information</title>
</head>
<body>
<div>
<h1 style="text-align: center; margin-bottom: 20px;">Worklenz Local Billing - Contact Information</h1>
<p><strong>Name:</strong> ${req.user?.name}</p>
<p><strong>Contact No:</strong> ${contactNo as string}</p>
<p><strong>Email:</strong> ${req.user?.email}</p>
</div>
</body>
</html>`;
const to = [process.env.CONTACT_US_EMAIL || "chamika@ceydigital.com"];
sendEmail({
to,
subject: "Worklenz - Local billing contact.",
html
});
return res.status(200).send(new ServerResponse(true, null, "Your contact information has been sent successfully."));
}
}

View File

@@ -12,7 +12,7 @@ export default class ClientsController extends WorklenzControllerBase {
@HandleExceptions()
public static async create(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
const q = `INSERT INTO clients (name, team_id) VALUES ($1, $2);`;
const q = `INSERT INTO clients (name, team_id) VALUES ($1, $2) RETURNING id, name;`;
const result = await db.query(q, [req.body.name, req.user?.team_id || null]);
const [data] = result.rows;
return res.status(200).send(new ServerResponse(true, data));

View File

@@ -0,0 +1,531 @@
import { IWorkLenzRequest } from "../interfaces/worklenz-request";
import { IWorkLenzResponse } from "../interfaces/worklenz-response";
import db from "../config/db";
import { ServerResponse } from "../models/server-response";
import WorklenzControllerBase from "./worklenz-controller-base";
import HandleExceptions from "../decorators/handle-exceptions";
export default class CustomcolumnsController extends WorklenzControllerBase {
@HandleExceptions()
public static async create(
req: IWorkLenzRequest,
res: IWorkLenzResponse
): Promise<IWorkLenzResponse> {
const {
project_id,
name,
key,
field_type,
width = 150,
is_visible = true,
configuration,
} = req.body;
// Start a transaction since we're inserting into multiple tables
const client = await db.pool.connect();
try {
await client.query("BEGIN");
// 1. Insert the main custom column
const columnQuery = `
INSERT INTO cc_custom_columns (
project_id, name, key, field_type, width, is_visible, is_custom_column
) VALUES ($1, $2, $3, $4, $5, $6, true)
RETURNING id;
`;
const columnResult = await client.query(columnQuery, [
project_id,
name,
key,
field_type,
width,
is_visible,
]);
const columnId = columnResult.rows[0].id;
// 2. Insert the column configuration
const configQuery = `
INSERT INTO cc_column_configurations (
column_id, field_title, field_type, number_type,
decimals, label, label_position, preview_value,
expression, first_numeric_column_key, second_numeric_column_key
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
RETURNING id;
`;
await client.query(configQuery, [
columnId,
configuration.field_title,
configuration.field_type,
configuration.number_type || null,
configuration.decimals || null,
configuration.label || null,
configuration.label_position || null,
configuration.preview_value || null,
configuration.expression || null,
configuration.first_numeric_column_key || null,
configuration.second_numeric_column_key || null,
]);
// 3. Insert selection options if present
if (
configuration.selections_list &&
configuration.selections_list.length > 0
) {
const selectionQuery = `
INSERT INTO cc_selection_options (
column_id, selection_id, selection_name, selection_color, selection_order
) VALUES ($1, $2, $3, $4, $5);
`;
for (const [
index,
selection,
] of configuration.selections_list.entries()) {
await client.query(selectionQuery, [
columnId,
selection.selection_id,
selection.selection_name,
selection.selection_color,
index,
]);
}
}
// 4. Insert label options if present
if (configuration.labels_list && configuration.labels_list.length > 0) {
const labelQuery = `
INSERT INTO cc_label_options (
column_id, label_id, label_name, label_color, label_order
) VALUES ($1, $2, $3, $4, $5);
`;
for (const [index, label] of configuration.labels_list.entries()) {
await client.query(labelQuery, [
columnId,
label.label_id,
label.label_name,
label.label_color,
index,
]);
}
}
await client.query("COMMIT");
// Fetch the complete column data
const getColumnQuery = `
SELECT
cc.*,
cf.field_title,
cf.number_type,
cf.decimals,
cf.label,
cf.label_position,
cf.preview_value,
cf.expression,
cf.first_numeric_column_key,
cf.second_numeric_column_key,
(
SELECT json_agg(
json_build_object(
'selection_id', so.selection_id,
'selection_name', so.selection_name,
'selection_color', so.selection_color
)
)
FROM cc_selection_options so
WHERE so.column_id = cc.id
) as selections_list,
(
SELECT json_agg(
json_build_object(
'label_id', lo.label_id,
'label_name', lo.label_name,
'label_color', lo.label_color
)
)
FROM cc_label_options lo
WHERE lo.column_id = cc.id
) as labels_list
FROM cc_custom_columns cc
LEFT JOIN cc_column_configurations cf ON cf.column_id = cc.id
WHERE cc.id = $1;
`;
const result = await client.query(getColumnQuery, [columnId]);
const [data] = result.rows;
return res.status(200).send(new ServerResponse(true, data));
} catch (error) {
await client.query("ROLLBACK");
throw error;
} finally {
client.release();
}
}
@HandleExceptions()
public static async get(
req: IWorkLenzRequest,
res: IWorkLenzResponse
): Promise<IWorkLenzResponse> {
const { project_id } = req.query;
const q = `
SELECT
cc.*,
cf.field_title,
cf.number_type,
cf.decimals,
cf.label,
cf.label_position,
cf.preview_value,
cf.expression,
cf.first_numeric_column_key,
cf.second_numeric_column_key,
(
SELECT json_agg(
json_build_object(
'selection_id', so.selection_id,
'selection_name', so.selection_name,
'selection_color', so.selection_color
)
)
FROM cc_selection_options so
WHERE so.column_id = cc.id
) as selections_list,
(
SELECT json_agg(
json_build_object(
'label_id', lo.label_id,
'label_name', lo.label_name,
'label_color', lo.label_color
)
)
FROM cc_label_options lo
WHERE lo.column_id = cc.id
) as labels_list
FROM cc_custom_columns cc
LEFT JOIN cc_column_configurations cf ON cf.column_id = cc.id
WHERE cc.project_id = $1
ORDER BY cc.created_at DESC;
`;
const result = await db.query(q, [project_id]);
return res.status(200).send(new ServerResponse(true, result.rows));
}
@HandleExceptions()
public static async getById(
req: IWorkLenzRequest,
res: IWorkLenzResponse
): Promise<IWorkLenzResponse> {
const { id } = req.params;
const q = `
SELECT
cc.*,
cf.field_title,
cf.number_type,
cf.decimals,
cf.label,
cf.label_position,
cf.preview_value,
cf.expression,
cf.first_numeric_column_key,
cf.second_numeric_column_key,
(
SELECT json_agg(
json_build_object(
'selection_id', so.selection_id,
'selection_name', so.selection_name,
'selection_color', so.selection_color
)
)
FROM cc_selection_options so
WHERE so.column_id = cc.id
) as selections_list,
(
SELECT json_agg(
json_build_object(
'label_id', lo.label_id,
'label_name', lo.label_name,
'label_color', lo.label_color
)
)
FROM cc_label_options lo
WHERE lo.column_id = cc.id
) as labels_list
FROM cc_custom_columns cc
LEFT JOIN cc_column_configurations cf ON cf.column_id = cc.id
WHERE cc.id = $1;
`;
const result = await db.query(q, [id]);
const [data] = result.rows;
return res.status(200).send(new ServerResponse(true, data));
}
@HandleExceptions()
public static async update(
req: IWorkLenzRequest,
res: IWorkLenzResponse
): Promise<IWorkLenzResponse> {
const { id } = req.params;
const { name, field_type, width, is_visible, configuration } = req.body;
const client = await db.pool.connect();
try {
await client.query("BEGIN");
// 1. Update the main custom column
const columnQuery = `
UPDATE cc_custom_columns
SET name = $1, field_type = $2, width = $3, is_visible = $4, updated_at = CURRENT_TIMESTAMP
WHERE id = $5
RETURNING id;
`;
await client.query(columnQuery, [
name,
field_type,
width,
is_visible,
id,
]);
// 2. Update the configuration
const configQuery = `
UPDATE cc_column_configurations
SET
field_title = $1,
field_type = $2,
number_type = $3,
decimals = $4,
label = $5,
label_position = $6,
preview_value = $7,
expression = $8,
first_numeric_column_key = $9,
second_numeric_column_key = $10,
updated_at = CURRENT_TIMESTAMP
WHERE column_id = $11;
`;
await client.query(configQuery, [
configuration.field_title,
configuration.field_type,
configuration.number_type || null,
configuration.decimals || null,
configuration.label || null,
configuration.label_position || null,
configuration.preview_value || null,
configuration.expression || null,
configuration.first_numeric_column_key || null,
configuration.second_numeric_column_key || null,
id,
]);
// 3. Update selections if present
if (configuration.selections_list) {
// Delete existing selections
await client.query(
"DELETE FROM cc_selection_options WHERE column_id = $1",
[id]
);
// Insert new selections
if (configuration.selections_list.length > 0) {
const selectionQuery = `
INSERT INTO cc_selection_options (
column_id, selection_id, selection_name, selection_color, selection_order
) VALUES ($1, $2, $3, $4, $5);
`;
for (const [
index,
selection,
] of configuration.selections_list.entries()) {
await client.query(selectionQuery, [
id,
selection.selection_id,
selection.selection_name,
selection.selection_color,
index,
]);
}
}
}
// 4. Update labels if present
if (configuration.labels_list) {
// Delete existing labels
await client.query("DELETE FROM cc_label_options WHERE column_id = $1", [
id,
]);
// Insert new labels
if (configuration.labels_list.length > 0) {
const labelQuery = `
INSERT INTO cc_label_options (
column_id, label_id, label_name, label_color, label_order
) VALUES ($1, $2, $3, $4, $5);
`;
for (const [index, label] of configuration.labels_list.entries()) {
await client.query(labelQuery, [
id,
label.label_id,
label.label_name,
label.label_color,
index,
]);
}
}
}
await client.query("COMMIT");
// Fetch the updated column data
const getColumnQuery = `
SELECT
cc.*,
cf.field_title,
cf.number_type,
cf.decimals,
cf.label,
cf.label_position,
cf.preview_value,
cf.expression,
cf.first_numeric_column_key,
cf.second_numeric_column_key,
(
SELECT json_agg(
json_build_object(
'selection_id', so.selection_id,
'selection_name', so.selection_name,
'selection_color', so.selection_color
)
)
FROM cc_selection_options so
WHERE so.column_id = cc.id
) as selections_list,
(
SELECT json_agg(
json_build_object(
'label_id', lo.label_id,
'label_name', lo.label_name,
'label_color', lo.label_color
)
)
FROM cc_label_options lo
WHERE lo.column_id = cc.id
) as labels_list
FROM cc_custom_columns cc
LEFT JOIN cc_column_configurations cf ON cf.column_id = cc.id
WHERE cc.id = $1;
`;
const result = await client.query(getColumnQuery, [id]);
const [data] = result.rows;
return res.status(200).send(new ServerResponse(true, data));
} catch (error) {
await client.query("ROLLBACK");
throw error;
} finally {
client.release();
}
}
@HandleExceptions()
public static async deleteById(
req: IWorkLenzRequest,
res: IWorkLenzResponse
): Promise<IWorkLenzResponse> {
const { id } = req.params;
const q = `
DELETE FROM cc_custom_columns
WHERE id = $1
RETURNING id;
`;
const result = await db.query(q, [id]);
return res.status(200).send(new ServerResponse(true, result.rows));
}
@HandleExceptions()
public static async getProjectColumns(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
const { project_id } = req.params;
const q = `
WITH column_data AS (
SELECT
cc.id,
cc.key,
cc.name,
cc.field_type,
cc.width,
cc.is_visible,
cf.field_title,
cf.number_type,
cf.decimals,
cf.label,
cf.label_position,
cf.preview_value,
cf.expression,
cf.first_numeric_column_key,
cf.second_numeric_column_key,
(
SELECT json_agg(
json_build_object(
'selection_id', so.selection_id,
'selection_name', so.selection_name,
'selection_color', so.selection_color
)
)
FROM cc_selection_options so
WHERE so.column_id = cc.id
) as selections_list,
(
SELECT json_agg(
json_build_object(
'label_id', lo.label_id,
'label_name', lo.label_name,
'label_color', lo.label_color
)
)
FROM cc_label_options lo
WHERE lo.column_id = cc.id
) as labels_list
FROM cc_custom_columns cc
LEFT JOIN cc_column_configurations cf ON cf.column_id = cc.id
WHERE cc.project_id = $1
)
SELECT
json_agg(
json_build_object(
'key', cd.key,
'id', cd.id,
'name', cd.name,
'width', cd.width,
'pinned', cd.is_visible,
'custom_column', true,
'custom_column_obj', json_build_object(
'fieldType', cd.field_type,
'fieldTitle', cd.field_title,
'numberType', cd.number_type,
'decimals', cd.decimals,
'label', cd.label,
'labelPosition', cd.label_position,
'previewValue', cd.preview_value,
'expression', cd.expression,
'firstNumericColumnKey', cd.first_numeric_column_key,
'secondNumericColumnKey', cd.second_numeric_column_key,
'selectionsList', COALESCE(cd.selections_list, '[]'::json),
'labelsList', COALESCE(cd.labels_list, '[]'::json)
)
)
) as columns
FROM column_data cd;
`;
const result = await db.query(q, [project_id]);
const columns = result.rows[0]?.columns || [];
return res.status(200).send(new ServerResponse(true, columns));
}
}

View File

@@ -114,7 +114,7 @@ export default class HomePageController extends WorklenzControllerBase {
p.team_id,
p.name AS project_name,
p.color_code AS project_color,
(SELECT id FROM task_statuses WHERE id = t.status_id) AS status,
(SELECT name FROM task_statuses WHERE id = t.status_id) AS status,
(SELECT color_code
FROM sys_task_status_categories
WHERE id = (SELECT category_id FROM task_statuses WHERE id = t.status_id)) AS status_color,

View File

@@ -59,7 +59,7 @@ export default class IndexController extends WorklenzControllerBase {
if (req.user && !req.user.is_member)
return res.redirect("/teams");
return res.redirect("/auth");
return res.redirect(301, "/auth");
}
public static redirectToLogin(req: IWorkLenzRequest, res: IWorkLenzResponse) {

View File

@@ -195,7 +195,7 @@ export default class ProjectCommentsController extends WorklenzControllerBase {
pc.created_at,
pc.updated_at
FROM project_comments pc
WHERE pc.project_id = $1 ORDER BY pc.updated_at DESC
WHERE pc.project_id = $1 ORDER BY pc.updated_at
`;
const result = await db.query(q, [req.params.id]);

View File

@@ -7,6 +7,9 @@ import WorklenzControllerBase from "./worklenz-controller-base";
import HandleExceptions from "../decorators/handle-exceptions";
import {getColor} from "../shared/utils";
import TeamMembersController from "./team-members-controller";
import {checkTeamSubscriptionStatus} from "../shared/paddle-utils";
import {updateUsers} from "../shared/paddle-requests";
import {statusExclude} from "../shared/constants";
import {NotificationsService} from "../services/notifications/notifications.service";
export default class ProjectMembersController extends WorklenzControllerBase {
@@ -69,6 +72,70 @@ export default class ProjectMembersController extends WorklenzControllerBase {
if (!req.user?.team_id) return res.status(200).send(new ServerResponse(false, "Required fields are missing."));
// check the subscription status
const subscriptionData = await checkTeamSubscriptionStatus(req.user?.team_id);
const userExists = await this.checkIfUserAlreadyExists(req.user?.owner_id as string, req.body.email);
// Return error if user already exists
if (userExists) {
return res.status(200).send(new ServerResponse(false, null, "User already exists in the team."));
}
// Handle self-hosted subscriptions differently
if (subscriptionData.subscription_type === 'SELF_HOSTED') {
// Adding as a team member
const teamMemberReq: { team_id?: string; emails: string[], project_id?: string; } = {
team_id: req.user?.team_id,
emails: [req.body.email]
};
if (req.body.project_id)
teamMemberReq.project_id = req.body.project_id;
const [member] = await TeamMembersController.createOrInviteMembers(teamMemberReq, req.user);
if (!member)
return res.status(200).send(new ServerResponse(true, null, "Failed to add the member to the project. Please try again."));
// Adding to the project
const projectMemberReq = {
team_member_id: member.team_member_id,
team_id: req.user?.team_id,
project_id: req.body.project_id,
user_id: req.user?.id,
access_level: req.body.access_level ? req.body.access_level : "MEMBER"
};
const data = await this.createOrInviteMembers(projectMemberReq);
return res.status(200).send(new ServerResponse(true, data.member));
}
if (statusExclude.includes(subscriptionData.subscription_status)) {
return res.status(200).send(new ServerResponse(false, null, "Unable to add user! Please check your subscription status."));
}
if (!userExists && subscriptionData.is_ltd && subscriptionData.current_count && (parseInt(subscriptionData.current_count) + 1 > parseInt(subscriptionData.ltd_users))) {
return res.status(200).send(new ServerResponse(false, null, "Maximum number of life time users reached."));
}
// if (subscriptionData.status === "trialing") break;
if (!userExists && !subscriptionData.is_credit && !subscriptionData.is_custom && subscriptionData.subscription_status !== "trialing") {
// if (subscriptionData.subscription_status === "active") {
// const response = await updateUsers(subscriptionData.subscription_id, (subscriptionData.quantity + 1));
// if (!response.body.subscription_id) return res.status(200).send(new ServerResponse(false, null, response.message || "Unable to add user! Please check your subscription."));
// }
const updatedCount = parseInt(subscriptionData.current_count) + 1;
const requiredSeats = updatedCount - subscriptionData.quantity;
if (updatedCount > subscriptionData.quantity) {
const obj = {
seats_enough: false,
required_count: requiredSeats,
current_seat_amount: subscriptionData.quantity
};
return res.status(200).send(new ServerResponse(false, obj, null));
}
}
// Adding as a team member
const teamMemberReq: { team_id?: string; emails: string[], project_id?: string; } = {
team_id: req.user?.team_id,

View File

@@ -8,6 +8,7 @@ import { templateData } from "./project-templates";
import ProjectTemplatesControllerBase from "./project-templates-base";
import { LOG_DESCRIPTIONS, TASK_PRIORITY_COLOR_ALPHA, TASK_STATUS_COLOR_ALPHA } from "../../shared/constants";
import { IO } from "../../shared/io";
import { getCurrentProjectsCount, getFreePlanSettings } from "../../shared/paddle-utils";
export default class ProjectTemplatesController extends ProjectTemplatesControllerBase {
@@ -46,10 +47,10 @@ export default class ProjectTemplatesController extends ProjectTemplatesControll
@HandleExceptions()
public static async getDefaultProjectHealth() {
const q = `SELECT id FROM sys_project_healths WHERE is_default IS TRUE`;
const result = await db.query(q, []);
const [data] = result.rows;
return data.id;
const q = `SELECT id FROM sys_project_healths WHERE is_default IS TRUE`;
const result = await db.query(q, []);
const [data] = result.rows;
return data.id;
}
@HandleExceptions()
@@ -92,6 +93,16 @@ export default class ProjectTemplatesController extends ProjectTemplatesControll
@HandleExceptions()
public static async importTemplates(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
if (req.user?.subscription_status === "free" && req.user?.owner_id) {
const limits = await getFreePlanSettings();
const projectsCount = await getCurrentProjectsCount(req.user.owner_id);
const projectsLimit = parseInt(limits.projects_limit);
if (parseInt(projectsCount) >= projectsLimit) {
return res.status(200).send(new ServerResponse(false, [], `Sorry, the free plan cannot have more than ${projectsLimit} projects.`));
}
}
const { template_id } = req.body;
let project_id: string | null = null;
@@ -202,6 +213,16 @@ export default class ProjectTemplatesController extends ProjectTemplatesControll
@HandleExceptions()
public static async importCustomTemplate(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
if (req.user?.subscription_status === "free" && req.user?.owner_id) {
const limits = await getFreePlanSettings();
const projectsCount = await getCurrentProjectsCount(req.user.owner_id);
const projectsLimit = parseInt(limits.projects_limit);
if (parseInt(projectsCount) >= projectsLimit) {
return res.status(200).send(new ServerResponse(false, [], `Sorry, the free plan cannot have more than ${projectsLimit} projects.`));
}
}
const { template_id } = req.body;
let project_id: string | null = null;
@@ -223,8 +244,8 @@ export default class ProjectTemplatesController extends ProjectTemplatesControll
await this.deleteDefaultStatusForProject(project_id as string);
await this.insertTeamLabels(data.labels, req.user?.team_id);
await this.insertProjectPhases(data.phases, project_id as string);
await this.insertProjectStatuses(data.status, project_id as string, data.team_id );
await this.insertProjectTasksFromCustom(data.tasks, data.team_id, project_id as string, data.user_id, IO.getSocketById(req.user?.socket_id as string));
await this.insertProjectStatuses(data.status, project_id as string, data.team_id);
await this.insertProjectTasksFromCustom(data.tasks, data.team_id, project_id as string, data.user_id, IO.getSocketById(req.user?.socket_id as string));
return res.status(200).send(new ServerResponse(true, { project_id }));
}

View File

@@ -12,6 +12,7 @@ import { NotificationsService } from "../services/notifications/notifications.se
import { IPassportSession } from "../interfaces/passport-session";
import { SocketEvents } from "../socket.io/events";
import { IO } from "../shared/io";
import { getCurrentProjectsCount, getFreePlanSettings } from "../shared/paddle-utils";
export default class ProjectsController extends WorklenzControllerBase {
@@ -61,6 +62,16 @@ export default class ProjectsController extends WorklenzControllerBase {
}
})
public static async create(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
if (req.user?.subscription_status === "free" && req.user?.owner_id) {
const limits = await getFreePlanSettings();
const projectsCount = await getCurrentProjectsCount(req.user.owner_id);
const projectsLimit = parseInt(limits.projects_limit);
if (parseInt(projectsCount) >= projectsLimit) {
return res.status(200).send(new ServerResponse(false, [], `Sorry, the free plan cannot have more than ${projectsLimit} projects.`));
}
}
const q = `SELECT create_project($1) AS project`;
req.body.team_id = req.user?.team_id || null;
@@ -689,7 +700,8 @@ export default class ProjectsController extends WorklenzControllerBase {
public static async toggleArchiveAll(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
const q = `SELECT toggle_archive_all_projects($1);`;
const result = await db.query(q, [req.params.id]);
return res.status(200).send(new ServerResponse(true, result.rows || []));
const [data] = result.rows;
return res.status(200).send(new ServerResponse(true, data.toggle_archive_all_projects || []));
}
public static async getProjectManager(projectId: string) {
@@ -698,4 +710,47 @@ export default class ProjectsController extends WorklenzControllerBase {
return result.rows || [];
}
public static async updateExistPhaseColors() {
const q = `SELECT id, name FROM project_phases`;
const phases = await db.query(q);
phases.rows.forEach((phase) => {
phase.color_code = getColor(phase.name);
});
const body = {
phases: phases.rows
};
const q2 = `SELECT update_existing_phase_colors($1)`;
await db.query(q2, [JSON.stringify(body)]);
}
public static async updateExistSortOrder() {
const q = `SELECT id, project_id FROM project_phases ORDER BY name`;
const phases = await db.query(q);
const sortNumbers: any = {};
phases.rows.forEach(phase => {
const projectId = phase.project_id;
if (!sortNumbers[projectId]) {
sortNumbers[projectId] = 0;
}
phase.sort_number = sortNumbers[projectId]++;
});
const body = {
phases: phases.rows
};
const q2 = `SELECT update_existing_phase_sort_order($1)`;
await db.query(q2, [JSON.stringify(body)]);
// return phases;
}
}

View File

@@ -1,4 +1,4 @@
import { IChartObject } from "./overview/reporting-overview-base";
import * as Highcharts from "highcharts";
export interface IDuration {
label: string;
@@ -34,7 +34,7 @@ export interface IOverviewStatistics {
}
export interface IChartData {
chart: IChartObject[];
chart: Highcharts.PointOptionsObject[];
}
export interface ITasksByStatus extends IChartData {

View File

@@ -1,4 +1,5 @@
import db from "../../../config/db";
import * as Highcharts from "highcharts";
import { ITasksByDue, ITasksByPriority, ITasksByStatus } from "../interfaces";
import ReportingControllerBase from "../reporting-controller-base";
import {
@@ -15,36 +16,33 @@ import {
TASK_STATUS_TODO_COLOR
} from "../../../shared/constants";
import { formatDuration, int } from "../../../shared/utils";
import PointOptionsObject from "../point-options-object";
import moment from "moment";
export interface IChartObject {
name: string,
color: string,
y: number
}
export default class ReportingOverviewBase extends ReportingControllerBase {
private static createChartObject(name: string, color: string, y: number) {
return {
name,
color,
y
};
}
protected static async getTeamsCounts(teamId: string | null, archivedQuery = "") {
const q = `
SELECT JSON_BUILD_OBJECT(
'teams', (SELECT COUNT(*) FROM teams WHERE in_organization(id, $1)),
'projects',
(SELECT COUNT(*) FROM projects WHERE in_organization(team_id, $1) ${archivedQuery}),
'team_members', (SELECT COUNT(DISTINCT email)
FROM team_member_info_view
WHERE in_organization(team_id, $1))
) AS counts;
`;
WITH team_count AS (
SELECT COUNT(*) AS count
FROM teams
WHERE in_organization(id, $1)
),
project_count AS (
SELECT COUNT(*) AS count
FROM projects
WHERE in_organization(team_id, $1) ${archivedQuery}
),
team_member_count AS (
SELECT COUNT(DISTINCT email) AS count
FROM team_member_info_view
WHERE in_organization(team_id, $1)
)
SELECT JSON_BUILD_OBJECT(
'teams', (SELECT count FROM team_count),
'projects', (SELECT count FROM project_count),
'team_members', (SELECT count FROM team_member_count)
) AS counts;`;
const res = await db.query(q, [teamId]);
const [data] = res.rows;
@@ -173,7 +171,7 @@ export default class ReportingOverviewBase extends ReportingControllerBase {
const doing = int(data?.counts.doing);
const done = int(data?.counts.done);
const chart: IChartObject[] = [];
const chart: Highcharts.PointOptionsObject[] = [];
return {
all,
@@ -209,7 +207,7 @@ export default class ReportingOverviewBase extends ReportingControllerBase {
const medium = int(data?.counts.medium);
const high = int(data?.counts.high);
const chart: IChartObject[] = [];
const chart: Highcharts.PointOptionsObject[] = [];
return {
all: 0,
@@ -237,7 +235,7 @@ export default class ReportingOverviewBase extends ReportingControllerBase {
const res = await db.query(q, [projectId]);
const [data] = res.rows;
const chart: IChartObject[] = [];
const chart: Highcharts.PointOptionsObject[] = [];
return {
all: 0,
@@ -251,26 +249,26 @@ export default class ReportingOverviewBase extends ReportingControllerBase {
protected static createByStatusChartData(body: ITasksByStatus) {
body.chart = [
this.createChartObject("Todo", TASK_STATUS_TODO_COLOR, body.todo),
this.createChartObject("Doing", TASK_STATUS_DOING_COLOR, body.doing),
this.createChartObject("Done", TASK_STATUS_DONE_COLOR, body.done),
new PointOptionsObject("Todo", TASK_STATUS_TODO_COLOR, body.todo),
new PointOptionsObject("Doing", TASK_STATUS_DOING_COLOR, body.doing),
new PointOptionsObject("Done", TASK_STATUS_DONE_COLOR, body.done),
];
}
protected static createByPriorityChartData(body: ITasksByPriority) {
body.chart = [
this.createChartObject("Low", TASK_PRIORITY_LOW_COLOR, body.low),
this.createChartObject("Medium", TASK_PRIORITY_MEDIUM_COLOR, body.medium),
this.createChartObject("High", TASK_PRIORITY_HIGH_COLOR, body.high),
new PointOptionsObject("Low", TASK_PRIORITY_LOW_COLOR, body.low),
new PointOptionsObject("Medium", TASK_PRIORITY_MEDIUM_COLOR, body.medium),
new PointOptionsObject("High", TASK_PRIORITY_HIGH_COLOR, body.high),
];
}
protected static createByDueDateChartData(body: ITasksByDue) {
body.chart = [
this.createChartObject("Completed", TASK_DUE_COMPLETED_COLOR, body.completed),
this.createChartObject("Upcoming", TASK_DUE_UPCOMING_COLOR, body.upcoming),
this.createChartObject("Overdue", TASK_DUE_OVERDUE_COLOR, body.overdue),
this.createChartObject("No due date", TASK_DUE_NO_DUE_COLOR, body.no_due),
new PointOptionsObject("Completed", TASK_DUE_COMPLETED_COLOR, body.completed),
new PointOptionsObject("Upcoming", TASK_DUE_UPCOMING_COLOR, body.upcoming),
new PointOptionsObject("Overdue", TASK_DUE_OVERDUE_COLOR, body.overdue),
new PointOptionsObject("No due date", TASK_DUE_NO_DUE_COLOR, body.no_due),
];
}
@@ -581,7 +579,7 @@ export default class ReportingOverviewBase extends ReportingControllerBase {
`;
const result = await db.query(q, [teamMemberId]);
const chart: IChartObject[] = [];
const chart: Highcharts.PointOptionsObject[] = [];
const total = result.rows.reduce((accumulator: number, current: {
count: number
@@ -589,7 +587,7 @@ export default class ReportingOverviewBase extends ReportingControllerBase {
for (const project of result.rows) {
project.count = int(project.count);
chart.push(this.createChartObject(project.label, project.color, project.count));
chart.push(new PointOptionsObject(project.label, project.color, project.count));
}
return { chart, total, data: result.rows };
@@ -635,7 +633,7 @@ export default class ReportingOverviewBase extends ReportingControllerBase {
`;
const result = await db.query(q, [teamMemberId]);
const chart: IChartObject[] = [];
const chart: Highcharts.PointOptionsObject[] = [];
const total = result.rows.reduce((accumulator: number, current: {
count: number
@@ -643,7 +641,7 @@ export default class ReportingOverviewBase extends ReportingControllerBase {
for (const project of result.rows) {
project.count = int(project.count);
chart.push(this.createChartObject(project.label, project.color, project.count));
chart.push(new PointOptionsObject(project.label, project.color, project.count));
}
return { chart, total, data: result.rows };
@@ -673,10 +671,10 @@ export default class ReportingOverviewBase extends ReportingControllerBase {
const total = int(d.low) + int(d.medium) + int(d.high);
const chart = [
this.createChartObject("Low", TASK_PRIORITY_LOW_COLOR, d.low),
this.createChartObject("Medium", TASK_PRIORITY_MEDIUM_COLOR, d.medium),
this.createChartObject("High", TASK_PRIORITY_HIGH_COLOR, d.high),
const chart: Highcharts.PointOptionsObject[] = [
new PointOptionsObject("Low", TASK_PRIORITY_LOW_COLOR, d.low),
new PointOptionsObject("Medium", TASK_PRIORITY_MEDIUM_COLOR, d.medium),
new PointOptionsObject("High", TASK_PRIORITY_HIGH_COLOR, d.high),
];
const data = [
@@ -730,10 +728,10 @@ export default class ReportingOverviewBase extends ReportingControllerBase {
const total = int(d.low) + int(d.medium) + int(d.high);
const chart = [
this.createChartObject("Low", TASK_PRIORITY_LOW_COLOR, d.low),
this.createChartObject("Medium", TASK_PRIORITY_MEDIUM_COLOR, d.medium),
this.createChartObject("High", TASK_PRIORITY_HIGH_COLOR, d.high),
const chart: Highcharts.PointOptionsObject[] = [
new PointOptionsObject("Low", TASK_PRIORITY_LOW_COLOR, d.low),
new PointOptionsObject("Medium", TASK_PRIORITY_MEDIUM_COLOR, d.medium),
new PointOptionsObject("High", TASK_PRIORITY_HIGH_COLOR, d.high),
];
const data = [
@@ -784,10 +782,10 @@ export default class ReportingOverviewBase extends ReportingControllerBase {
const total = int(d.total);
const chart = [
this.createChartObject("Todo", TASK_STATUS_TODO_COLOR, d.todo),
this.createChartObject("Doing", TASK_STATUS_DOING_COLOR, d.doing),
this.createChartObject("Done", TASK_STATUS_DONE_COLOR, d.done),
const chart: Highcharts.PointOptionsObject[] = [
new PointOptionsObject("Todo", TASK_STATUS_TODO_COLOR, d.todo),
new PointOptionsObject("Doing", TASK_STATUS_DOING_COLOR, d.doing),
new PointOptionsObject("Done", TASK_STATUS_DONE_COLOR, d.done),
];
const data = [
@@ -826,10 +824,10 @@ export default class ReportingOverviewBase extends ReportingControllerBase {
const total = int(d.todo) + int(d.doing) + int(d.done);
const chart = [
this.createChartObject("Todo", TASK_STATUS_TODO_COLOR, d.todo),
this.createChartObject("Doing", TASK_STATUS_DOING_COLOR, d.doing),
this.createChartObject("Done", TASK_STATUS_DONE_COLOR, d.done),
const chart: Highcharts.PointOptionsObject[] = [
new PointOptionsObject("Todo", TASK_STATUS_TODO_COLOR, d.todo),
new PointOptionsObject("Doing", TASK_STATUS_DOING_COLOR, d.doing),
new PointOptionsObject("Done", TASK_STATUS_DONE_COLOR, d.done),
];
const data = [
@@ -878,7 +876,7 @@ export default class ReportingOverviewBase extends ReportingControllerBase {
const in_progress = int(data?.counts.in_progress);
const completed = int(data?.counts.completed);
const chart : IChartObject[] = [];
const chart: Highcharts.PointOptionsObject[] = [];
return {
all,
@@ -908,7 +906,7 @@ export default class ReportingOverviewBase extends ReportingControllerBase {
`;
const result = await db.query(q, [teamId]);
const chart: IChartObject[] = [];
const chart: Highcharts.PointOptionsObject[] = [];
const total = result.rows.reduce((accumulator: number, current: {
count: number
@@ -916,11 +914,7 @@ export default class ReportingOverviewBase extends ReportingControllerBase {
for (const category of result.rows) {
category.count = int(category.count);
chart.push({
name: category.label,
color: category.color,
y: category.count
});
chart.push(new PointOptionsObject(category.label, category.color, category.count));
}
return { chart, total, data: result.rows };
@@ -956,7 +950,7 @@ export default class ReportingOverviewBase extends ReportingControllerBase {
const at_risk = int(data?.counts.at_risk);
const good = int(data?.counts.good);
const chart: IChartObject[] = [];
const chart: Highcharts.PointOptionsObject[] = [];
return {
not_set,
@@ -971,22 +965,22 @@ export default class ReportingOverviewBase extends ReportingControllerBase {
// Team Overview
protected static createByProjectStatusChartData(body: any) {
body.chart = [
this.createChartObject("Cancelled", "#f37070", body.cancelled),
this.createChartObject("Blocked", "#cbc8a1", body.blocked),
this.createChartObject("On Hold", "#cbc8a1", body.on_hold),
this.createChartObject("Proposed", "#cbc8a1", body.proposed),
this.createChartObject("In Planning", "#cbc8a1", body.in_planning),
this.createChartObject("In Progress", "#80ca79", body.in_progress),
this.createChartObject("Completed", "#80ca79", body.completed)
new PointOptionsObject("Cancelled", "#f37070", body.cancelled),
new PointOptionsObject("Blocked", "#cbc8a1", body.blocked),
new PointOptionsObject("On Hold", "#cbc8a1", body.on_hold),
new PointOptionsObject("Proposed", "#cbc8a1", body.proposed),
new PointOptionsObject("In Planning", "#cbc8a1", body.in_planning),
new PointOptionsObject("In Progress", "#80ca79", body.in_progress),
new PointOptionsObject("Completed", "#80ca79", body.completed),
];
}
protected static createByProjectHealthChartData(body: any) {
body.chart = [
this.createChartObject("Not Set", "#a9a9a9", body.not_set),
this.createChartObject("Needs Attention", "#f37070", body.needs_attention),
this.createChartObject("At Risk", "#fbc84c", body.at_risk),
this.createChartObject("Good", "#75c997", body.good)
new PointOptionsObject("Not Set", "#a9a9a9", body.not_set),
new PointOptionsObject("Needs Attention", "#f37070", body.needs_attention),
new PointOptionsObject("At Risk", "#fbc84c", body.at_risk),
new PointOptionsObject("Good", "#75c997", body.good)
];
}

View File

@@ -0,0 +1,13 @@
import * as Highcharts from "highcharts";
export default class PointOptionsObject implements Highcharts.PointOptionsObject {
name!: string;
color!: string;
y!: number;
constructor(name: string, color: string, y: number) {
this.name = name;
this.color = color;
this.y = y;
}
}

View File

@@ -8,13 +8,14 @@ import { getColor, int, log_error } from "../../shared/utils";
import ReportingControllerBase from "./reporting-controller-base";
import { DATE_RANGES } from "../../shared/constants";
import Excel from "exceljs";
import ChartJsImage from "chartjs-to-image";
enum IToggleOptions {
'WORKING_DAYS' = 'WORKING_DAYS', 'MAN_DAYS' = 'MAN_DAYS'
}
export default class ReportingAllocationController extends ReportingControllerBase {
private static async getTimeLoggedByProjects(projects: string[], users: string[], key: string, dateRange: string[], archived = false, user_id = ""): Promise<any> {
private static async getTimeLoggedByProjects(projects: string[], users: string[], key: string, dateRange: string[], archived = false, user_id = "", billable: { billable: boolean; nonBillable: boolean }): Promise<any> {
try {
const projectIds = projects.map(p => `'${p}'`).join(",");
const userIds = users.map(u => `'${u}'`).join(",");
@@ -24,8 +25,10 @@ export default class ReportingAllocationController extends ReportingControllerBa
? ""
: `AND projects.id NOT IN (SELECT project_id FROM archived_projects WHERE project_id = projects.id AND user_id = '${user_id}') `;
const projectTimeLogs = await this.getTotalTimeLogsByProject(archived, duration, projectIds, userIds, archivedClause);
const userTimeLogs = await this.getTotalTimeLogsByUser(archived, duration, projectIds, userIds);
const billableQuery = this.buildBillableQuery(billable);
const projectTimeLogs = await this.getTotalTimeLogsByProject(archived, duration, projectIds, userIds, archivedClause, billableQuery);
const userTimeLogs = await this.getTotalTimeLogsByUser(archived, duration, projectIds, userIds, billableQuery);
const format = (seconds: number) => {
if (seconds === 0) return "-";
@@ -65,7 +68,7 @@ export default class ReportingAllocationController extends ReportingControllerBa
return [];
}
private static async getTotalTimeLogsByProject(archived: boolean, duration: string, projectIds: string, userIds: string, archivedClause = "") {
private static async getTotalTimeLogsByProject(archived: boolean, duration: string, projectIds: string, userIds: string, archivedClause = "", billableQuery = '') {
try {
const q = `SELECT projects.name,
projects.color_code,
@@ -74,12 +77,12 @@ export default class ReportingAllocationController extends ReportingControllerBa
sps.icon AS status_icon,
(SELECT COUNT(*)
FROM tasks
WHERE CASE WHEN ($1 IS TRUE) THEN project_id IS NOT NULL ELSE archived = FALSE END
WHERE CASE WHEN ($1 IS TRUE) THEN project_id IS NOT NULL ELSE archived = FALSE END ${billableQuery}
AND project_id = projects.id) AS all_tasks_count,
(SELECT COUNT(*)
FROM tasks
WHERE CASE WHEN ($1 IS TRUE) THEN project_id IS NOT NULL ELSE archived = FALSE END
AND project_id = projects.id
AND project_id = projects.id ${billableQuery}
AND status_id IN (SELECT id
FROM task_statuses
WHERE project_id = projects.id
@@ -91,10 +94,10 @@ export default class ReportingAllocationController extends ReportingControllerBa
SELECT name,
(SELECT COALESCE(SUM(time_spent), 0)
FROM task_work_log
LEFT JOIN tasks t ON task_work_log.task_id = t.id
WHERE user_id = users.id
AND CASE WHEN ($1 IS TRUE) THEN t.project_id IS NOT NULL ELSE t.archived = FALSE END
AND t.project_id = projects.id
LEFT JOIN tasks ON task_work_log.task_id = tasks.id
WHERE user_id = users.id ${billableQuery}
AND CASE WHEN ($1 IS TRUE) THEN tasks.project_id IS NOT NULL ELSE tasks.archived = FALSE END
AND tasks.project_id = projects.id
${duration}) AS time_logged
FROM users
WHERE id IN (${userIds})
@@ -113,15 +116,15 @@ export default class ReportingAllocationController extends ReportingControllerBa
}
}
private static async getTotalTimeLogsByUser(archived: boolean, duration: string, projectIds: string, userIds: string) {
private static async getTotalTimeLogsByUser(archived: boolean, duration: string, projectIds: string, userIds: string, billableQuery = "") {
try {
const q = `(SELECT id,
(SELECT COALESCE(SUM(time_spent), 0)
FROM task_work_log
LEFT JOIN tasks t ON task_work_log.task_id = t.id
LEFT JOIN tasks ON task_work_log.task_id = tasks.id ${billableQuery}
WHERE user_id = users.id
AND CASE WHEN ($1 IS TRUE) THEN t.project_id IS NOT NULL ELSE t.archived = FALSE END
AND t.project_id IN (${projectIds})
AND CASE WHEN ($1 IS TRUE) THEN tasks.project_id IS NOT NULL ELSE tasks.archived = FALSE END
AND tasks.project_id IN (${projectIds})
${duration}) AS time_logged
FROM users
WHERE id IN (${userIds})
@@ -154,6 +157,7 @@ export default class ReportingAllocationController extends ReportingControllerBa
@HandleExceptions()
public static async getAllocation(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
const teams = (req.body.teams || []) as string[]; // ids
const billable = req.body.billable;
const teamIds = teams.map(id => `'${id}'`).join(",");
const projectIds = (req.body.projects || []) as string[];
@@ -164,7 +168,7 @@ export default class ReportingAllocationController extends ReportingControllerBa
const users = await this.getUserIds(teamIds);
const userIds = users.map((u: any) => u.id);
const { projectTimeLogs, userTimeLogs } = await this.getTimeLoggedByProjects(projectIds, userIds, req.body.duration, req.body.date_range, (req.query.archived === "true"), req.user?.id);
const { projectTimeLogs, userTimeLogs } = await this.getTimeLoggedByProjects(projectIds, userIds, req.body.duration, req.body.date_range, (req.query.archived === "true"), req.user?.id, billable);
for (const [i, user] of users.entries()) {
user.total_time = userTimeLogs[i].time_logged;
@@ -184,6 +188,7 @@ export default class ReportingAllocationController extends ReportingControllerBa
public static async export(req: IWorkLenzRequest, res: IWorkLenzResponse) {
const teams = (req.query.teams as string)?.split(",");
const teamIds = teams.map(t => `'${t}'`).join(",");
const billable = req.body.billable ? req.body.billable : { billable: req.query.billable === "true", nonBillable: req.query.nonBillable === "true" };
const projectIds = (req.query.projects as string)?.split(",");
@@ -218,7 +223,7 @@ export default class ReportingAllocationController extends ReportingControllerBa
const users = await this.getUserIds(teamIds);
const userIds = users.map((u: any) => u.id);
const { projectTimeLogs, userTimeLogs } = await this.getTimeLoggedByProjects(projectIds, userIds, duration as string, dateRange, (req.query.include_archived === "true"), req.user?.id);
const { projectTimeLogs, userTimeLogs } = await this.getTimeLoggedByProjects(projectIds, userIds, duration as string, dateRange, (req.query.include_archived === "true"), req.user?.id, billable);
for (const [i, user] of users.entries()) {
user.total_time = userTimeLogs[i].time_logged;
@@ -341,6 +346,8 @@ export default class ReportingAllocationController extends ReportingControllerBa
const projects = (req.body.projects || []) as string[];
const projectIds = projects.map(p => `'${p}'`).join(",");
const billable = req.body.billable;
if (!teamIds || !projectIds.length)
return res.status(200).send(new ServerResponse(true, { users: [], projects: [] }));
@@ -352,6 +359,8 @@ export default class ReportingAllocationController extends ReportingControllerBa
? ""
: `AND p.id NOT IN (SELECT project_id FROM archived_projects WHERE project_id = p.id AND user_id = '${req.user?.id}') `;
const billableQuery = this.buildBillableQuery(billable);
const q = `
SELECT p.id,
p.name,
@@ -359,8 +368,8 @@ export default class ReportingAllocationController extends ReportingControllerBa
SUM(total_minutes) AS estimated,
color_code
FROM projects p
LEFT JOIN tasks t ON t.project_id = p.id
LEFT JOIN task_work_log ON task_work_log.task_id = t.id
LEFT JOIN tasks ON tasks.project_id = p.id ${billableQuery}
LEFT JOIN task_work_log ON task_work_log.task_id = tasks.id
WHERE p.id IN (${projectIds}) ${durationClause} ${archivedClause}
GROUP BY p.id, p.name
ORDER BY logged_time DESC;`;
@@ -372,7 +381,7 @@ export default class ReportingAllocationController extends ReportingControllerBa
project.value = project.logged_time ? parseFloat(moment.duration(project.logged_time, "seconds").asHours().toFixed(2)) : 0;
project.estimated_value = project.estimated ? parseFloat(moment.duration(project.estimated, "minutes").asHours().toFixed(2)) : 0;
if (project.value > 0 ) {
if (project.value > 0) {
data.push(project);
}
@@ -392,6 +401,8 @@ export default class ReportingAllocationController extends ReportingControllerBa
const projects = (req.body.projects || []) as string[];
const projectIds = projects.map(p => `'${p}'`).join(",");
const billable = req.body.billable;
if (!teamIds || !projectIds.length)
return res.status(200).send(new ServerResponse(true, { users: [], projects: [] }));
@@ -402,12 +413,14 @@ export default class ReportingAllocationController extends ReportingControllerBa
? ""
: `AND p.id NOT IN (SELECT project_id FROM archived_projects WHERE project_id = p.id AND user_id = '${req.user?.id}') `;
const billableQuery = this.buildBillableQuery(billable);
const q = `
SELECT tmiv.email, tmiv.name, SUM(time_spent) AS logged_time
FROM team_member_info_view tmiv
LEFT JOIN task_work_log ON task_work_log.user_id = tmiv.user_id
LEFT JOIN tasks t ON t.id = task_work_log.task_id
LEFT JOIN projects p ON p.id = t.project_id AND p.team_id = tmiv.team_id
LEFT JOIN tasks ON tasks.id = task_work_log.task_id ${billableQuery}
LEFT JOIN projects p ON p.id = tasks.project_id AND p.team_id = tmiv.team_id
WHERE p.id IN (${projectIds})
${durationClause} ${archivedClause}
GROUP BY tmiv.email, tmiv.name
@@ -422,7 +435,64 @@ export default class ReportingAllocationController extends ReportingControllerBa
return res.status(200).send(new ServerResponse(true, result.rows));
}
@HandleExceptions()
public static async exportTest(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
const archived = req.query.archived === "true";
const teamId = this.getCurrentTeamId(req);
const { duration, date_range } = req.query;
const durationClause = this.getDateRangeClause(duration as string || DATE_RANGES.LAST_WEEK, date_range as string[]);
const archivedClause = archived
? ""
: `AND p.id NOT IN (SELECT project_id FROM archived_projects WHERE project_id = p.id AND user_id = '${req.user?.id}') `;
const q = `
SELECT p.id,
p.name,
(SELECT SUM(time_spent)) AS logged_time,
SUM(total_minutes) AS estimated,
color_code
FROM projects p
LEFT JOIN tasks t ON t.project_id = p.id
LEFT JOIN task_work_log ON task_work_log.task_id = t.id
WHERE in_organization(p.team_id, $1)
${durationClause} ${archivedClause}
GROUP BY p.id, p.name
ORDER BY p.name ASC;`;
const result = await db.query(q, [teamId]);
const labelsX = [];
const dataX = [];
for (const project of result.rows) {
project.value = project.logged_time ? parseFloat(moment.duration(project.logged_time, "seconds").asHours().toFixed(2)) : 0;
project.estimated_value = project.estimated ? parseFloat(moment.duration(project.estimated, "minutes").asHours().toFixed(2)) : 0;
labelsX.push(project.name);
dataX.push(project.value || 0);
}
const chart = new ChartJsImage();
chart.setConfig({
type: "bar",
data: {
labels: labelsX,
datasets: [
{ label: "", data: dataX }
]
},
});
chart.setWidth(1920).setHeight(1080).setBackgroundColor("transparent");
const url = chart.getUrl();
chart.toFile("test.png");
return res.status(200).send(new ServerResponse(true, url));
}
private static getEstimated(project: any, type: string) {
// if (project.estimated_man_days === 0 || project.estimated_working_days === 0) {
// return (parseFloat(moment.duration(project.estimated, "minutes").asHours().toFixed(2)) / int(project.hours_per_day)).toFixed(2)
// }
switch (type) {
case IToggleOptions.MAN_DAYS:
@@ -445,7 +515,7 @@ export default class ReportingAllocationController extends ReportingControllerBa
const projects = (req.body.projects || []) as string[];
const projectIds = projects.map(p => `'${p}'`).join(",");
const { type } = req.body;
const { type, billable } = req.body;
if (!teamIds || !projectIds.length)
return res.status(200).send(new ServerResponse(true, { users: [], projects: [] }));
@@ -458,6 +528,8 @@ export default class ReportingAllocationController extends ReportingControllerBa
? ""
: `AND p.id NOT IN (SELECT project_id FROM archived_projects WHERE project_id = p.id AND user_id = '${req.user?.id}') `;
const billableQuery = this.buildBillableQuery(billable);
const q = `
SELECT p.id,
p.name,
@@ -471,8 +543,8 @@ export default class ReportingAllocationController extends ReportingControllerBa
WHERE project_id = p.id) AS estimated,
color_code
FROM projects p
LEFT JOIN tasks t ON t.project_id = p.id
LEFT JOIN task_work_log ON task_work_log.task_id = t.id
LEFT JOIN tasks ON tasks.project_id = p.id ${billableQuery}
LEFT JOIN task_work_log ON task_work_log.task_id = tasks.id
WHERE p.id IN (${projectIds}) ${durationClause} ${archivedClause}
GROUP BY p.id, p.name
ORDER BY logged_time DESC;`;
@@ -491,7 +563,7 @@ export default class ReportingAllocationController extends ReportingControllerBa
project.estimated_working_days = project.estimated_working_days ?? 0;
project.hours_per_day = project.hours_per_day ?? 0;
if (project.value > 0 || project.estimated_value > 0 ) {
if (project.value > 0 || project.estimated_value > 0) {
data.push(project);
}

View File

@@ -109,6 +109,23 @@ export default abstract class ReportingControllerBase extends WorklenzController
return "";
}
protected static buildBillableQuery(selectedStatuses: { billable: boolean; nonBillable: boolean }): string {
const { billable, nonBillable } = selectedStatuses;
if (billable && nonBillable) {
// Both are enabled, no need to filter
return "";
} else if (billable) {
// Only billable is enabled
return " AND tasks.billable IS TRUE";
} else if (nonBillable) {
// Only non-billable is enabled
return " AND tasks.billable IS FALSE";
}
return "";
}
protected static formatEndDate(endDate: string) {
const end = moment(endDate).format("YYYY-MM-DD");
const fEndDate = moment(end);
@@ -173,6 +190,9 @@ export default abstract class ReportingControllerBase extends WorklenzController
(SELECT color_code
FROM sys_project_healths
WHERE sys_project_healths.id = p.health_id) AS health_color,
(SELECT name
FROM sys_project_healths
WHERE sys_project_healths.id = p.health_id) AS health_name,
pc.id AS category_id,
pc.name AS category_name,

View File

@@ -862,7 +862,7 @@ export default class ReportingMembersController extends ReportingControllerBase
}
private static async memberTimeLogsData(durationClause: string, minMaxDateClause: string, team_id: string, team_member_id: string, includeArchived: boolean, userId: string) {
private static async memberTimeLogsData(durationClause: string, minMaxDateClause: string, team_id: string, team_member_id: string, includeArchived: boolean, userId: string, billableQuery = "") {
const archivedClause = includeArchived
? ""
@@ -884,7 +884,7 @@ export default class ReportingMembersController extends ReportingControllerBase
FROM task_work_log twl
WHERE twl.user_id = tmiv.user_id
${durationClause}
AND task_id IN (SELECT id FROM tasks WHERE project_id IN (SELECT id FROM projects WHERE team_id = $1) ${archivedClause} )
AND task_id IN (SELECT id FROM tasks WHERE project_id IN (SELECT id FROM projects WHERE team_id = $1) ${archivedClause} ${billableQuery})
ORDER BY twl.updated_at DESC) tl) AS time_logs
${minMaxDateClause}
FROM team_member_info_view tmiv
@@ -1017,14 +1017,33 @@ export default class ReportingMembersController extends ReportingControllerBase
}
protected static buildBillableQuery(selectedStatuses: { billable: boolean; nonBillable: boolean }): string {
const { billable, nonBillable } = selectedStatuses;
if (billable && nonBillable) {
// Both are enabled, no need to filter
return "";
} else if (billable) {
// Only billable is enabled
return " AND tasks.billable IS TRUE";
} else if (nonBillable) {
// Only non-billable is enabled
return " AND tasks.billable IS FALSE";
}
return "";
}
@HandleExceptions()
public static async getMemberTimelogs(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
const { team_member_id, team_id, duration, date_range, archived } = req.body;
const { team_member_id, team_id, duration, date_range, archived, billable } = req.body;
const durationClause = ReportingMembersController.getDateRangeClauseMembers(duration || DATE_RANGES.LAST_WEEK, date_range, "twl");
const minMaxDateClause = this.getMinMaxDates(duration || DATE_RANGES.LAST_WEEK, date_range, "task_work_log");
const logGroups = await this.memberTimeLogsData(durationClause, minMaxDateClause, team_id, team_member_id, archived, req.user?.id as string);
const billableQuery = this.buildBillableQuery(billable);
const logGroups = await this.memberTimeLogsData(durationClause, minMaxDateClause, team_id, team_member_id, archived, req.user?.id as string, billableQuery);
return res.status(200).send(new ServerResponse(true, logGroups));
}
@@ -1049,6 +1068,7 @@ export default class ReportingMembersController extends ReportingControllerBase
const completedDurationClasue = this.completedDurationFilter(duration as string, dateRange);
const overdueClauseByDate = this.getActivityLogsOverdue(duration as string, dateRange);
const taskSelectorClause = this.getTaskSelectorClause();
const durationFilter = this.memberTasksDurationFilter(duration as string, dateRange);
const q = `
SELECT name AS team_member_name,
@@ -1059,6 +1079,12 @@ export default class ReportingMembersController extends ReportingControllerBase
LEFT JOIN tasks_assignees ta ON t.id = ta.task_id
WHERE ta.team_member_id = $1 ${assignClause} ${archivedClause}) assigned) AS assigned,
(SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(assigned))), '[]')
FROM (${taskSelectorClause}
FROM tasks t
LEFT JOIN tasks_assignees ta ON t.id = ta.task_id
WHERE ta.team_member_id = $1 ${durationFilter} ${assignClause} ${archivedClause}) assigned) AS total,
(SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(completed))), '[]')
FROM (${taskSelectorClause}
FROM tasks t
@@ -1095,6 +1121,11 @@ export default class ReportingMembersController extends ReportingControllerBase
const body = {
team_member_name: data.team_member_name,
groups: [
{
name: "Total Tasks",
color_code: "#7590c9",
tasks: data.total ? data.total : 0
},
{
name: "Tasks Assigned",
color_code: "#7590c9",
@@ -1114,7 +1145,7 @@ export default class ReportingMembersController extends ReportingControllerBase
name: "Tasks Ongoing",
color_code: "#7cb5ec",
tasks: data.ongoing ? data.ongoing : 0
}
},
]
};

View File

@@ -0,0 +1,407 @@
import db from "../../config/db";
import { ParsedQs } from "qs";
import HandleExceptions from "../../decorators/handle-exceptions";
import { IWorkLenzRequest } from "../../interfaces/worklenz-request";
import { IWorkLenzResponse } from "../../interfaces/worklenz-response";
import { ServerResponse } from "../../models/server-response";
import { TASK_PRIORITY_COLOR_ALPHA, TASK_STATUS_COLOR_ALPHA, UNMAPPED } from "../../shared/constants";
import { getColor } from "../../shared/utils";
import moment, { Moment } from "moment";
import momentTime from "moment-timezone";
import WorklenzControllerBase from "../worklenz-controller-base";
interface IDateUnions {
date_union: {
start_date: string | null;
end_date: string | null;
},
logs_date_union: {
start_date: string | null;
end_date: string | null;
},
allocated_date_union: {
start_date: string | null;
end_date: string | null;
}
}
interface IDatesPair {
start_date: string | null,
end_date: string | null
}
export default class ScheduleControllerV2 extends WorklenzControllerBase {
@HandleExceptions()
public static async getSettings(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
// get organization working days
const getDataq = `SELECT organization_id, array_agg(initcap(day)) AS working_days
FROM (
SELECT organization_id,
unnest(ARRAY['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday']) AS day,
unnest(ARRAY[monday, tuesday, wednesday, thursday, friday, saturday, sunday]) AS is_working
FROM public.organization_working_days
WHERE organization_id IN (
SELECT id FROM organizations
WHERE user_id = $1
)
) t
WHERE t.is_working
GROUP BY organization_id LIMIT 1;`;
const workingDaysResults = await db.query(getDataq, [req.user?.owner_id]);
const [workingDays] = workingDaysResults.rows;
// get organization working hours
const getDataHoursq = `SELECT working_hours FROM organizations WHERE user_id = $1 GROUP BY id LIMIT 1;`;
const workingHoursResults = await db.query(getDataHoursq, [req.user?.owner_id]);
const [workingHours] = workingHoursResults.rows;
return res.status(200).send(new ServerResponse(true, { workingDays: workingDays?.working_days, workingHours: workingHours?.working_hours }));
}
@HandleExceptions()
public static async updateSettings(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
const { workingDays, workingHours } = req.body;
// Days of the week
const days = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"];
// Generate the SET clause dynamically
const setClause = days
.map(day => `${day.toLowerCase()} = ${workingDays.includes(day)}`)
.join(", ");
const updateQuery = `
UPDATE public.organization_working_days
SET ${setClause}, updated_at = CURRENT_TIMESTAMP
WHERE organization_id IN (
SELECT organization_id FROM organizations
WHERE user_id = $1
);
`;
await db.query(updateQuery, [req.user?.owner_id]);
const getDataHoursq = `UPDATE organizations SET working_hours = $1 WHERE user_id = $2;`;
await db.query(getDataHoursq, [workingHours, req.user?.owner_id]);
return res.status(200).send(new ServerResponse(true, {}));
}
@HandleExceptions()
public static async getDates(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
const { date, type } = req.params;
if (type === "week") {
const getDataq = `WITH input_date AS (
SELECT
$1::DATE AS given_date,
(SELECT id FROM organizations WHERE user_id=$2 LIMIT 1) AS organization_id
),
week_range AS (
SELECT
(given_date - (EXTRACT(DOW FROM given_date)::INT + 6) % 7)::DATE AS start_date, -- Current week start date
(given_date - (EXTRACT(DOW FROM given_date)::INT + 6) % 7 + 6)::DATE AS end_date, -- Current week end date
(given_date - (EXTRACT(DOW FROM given_date)::INT + 6) % 7 + 7)::DATE AS next_week_start, -- Next week start date
(given_date - (EXTRACT(DOW FROM given_date)::INT + 6) % 7 + 13)::DATE AS next_week_end, -- Next week end date
TO_CHAR(given_date, 'Mon YYYY') AS month_year, -- Format the month as 'Jan 2025'
EXTRACT(DAY FROM given_date) AS day_number, -- Extract the day from the date
(given_date - (EXTRACT(DOW FROM given_date)::INT + 6) % 7)::DATE AS chart_start, -- First week start date
(given_date - (EXTRACT(DOW FROM given_date)::INT + 6) % 7 + 13)::DATE AS chart_end, -- Second week end date
CURRENT_DATE::DATE AS today,
organization_id
FROM input_date
),
org_working_days AS (
SELECT
organization_id,
monday, tuesday, wednesday, thursday, friday, saturday, sunday
FROM organization_working_days
WHERE organization_id = (SELECT organization_id FROM week_range)
),
days AS (
SELECT
generate_series((SELECT start_date FROM week_range), (SELECT next_week_end FROM week_range), '1 day'::INTERVAL)::DATE AS date
),
formatted_days AS (
SELECT
d.date,
TO_CHAR(d.date, 'Dy') AS day_name,
EXTRACT(DAY FROM d.date) AS day,
TO_CHAR(d.date, 'Mon YYYY') AS month, -- Format the month as 'Jan 2025'
CASE
WHEN EXTRACT(DOW FROM d.date) = 0 THEN (SELECT sunday FROM org_working_days)
WHEN EXTRACT(DOW FROM d.date) = 1 THEN (SELECT monday FROM org_working_days)
WHEN EXTRACT(DOW FROM d.date) = 2 THEN (SELECT tuesday FROM org_working_days)
WHEN EXTRACT(DOW FROM d.date) = 3 THEN (SELECT wednesday FROM org_working_days)
WHEN EXTRACT(DOW FROM d.date) = 4 THEN (SELECT thursday FROM org_working_days)
WHEN EXTRACT(DOW FROM d.date) = 5 THEN (SELECT friday FROM org_working_days)
WHEN EXTRACT(DOW FROM d.date) = 6 THEN (SELECT saturday FROM org_working_days)
END AS is_weekend,
CASE WHEN d.date = (SELECT today FROM week_range) THEN TRUE ELSE FALSE END AS is_today
FROM days d
),
aggregated_days AS (
SELECT
jsonb_agg(
jsonb_build_object(
'day', day,
'month', month, -- Include formatted month
'name', day_name,
'isWeekend', NOT is_weekend,
'isToday', is_today
) ORDER BY date
) AS days_json
FROM formatted_days
)
SELECT jsonb_build_object(
'date_data', jsonb_agg(
jsonb_build_object(
'month', (SELECT month_year FROM week_range), -- Formatted month-year (e.g., Jan 2025)
'day', (SELECT day_number FROM week_range), -- Dynamic day number
'weeks', '[]', -- Empty weeks array for now
'days', (SELECT days_json FROM aggregated_days) -- Aggregated days data
)
),
'chart_start', (SELECT chart_start FROM week_range), -- First week start date
'chart_end', (SELECT chart_end FROM week_range) -- Second week end date
) AS result_json;`;
const results = await db.query(getDataq, [date, req.user?.owner_id]);
const [data] = results.rows;
return res.status(200).send(new ServerResponse(true, data.result_json));
} else if (type === "month") {
const getDataq = `WITH params AS (
SELECT
DATE_TRUNC('month', $1::DATE)::DATE AS start_date, -- First day of the month
(DATE_TRUNC('month', $1::DATE) + INTERVAL '1 month' - INTERVAL '1 day')::DATE AS end_date, -- Last day of the month
CURRENT_DATE::DATE AS today,
(SELECT id FROM organizations WHERE user_id = $2 LIMIT 1) AS org_id
),
days AS (
SELECT
generate_series(
(SELECT start_date FROM params),
(SELECT end_date FROM params),
'1 day'::INTERVAL
)::DATE AS date
),
org_working_days AS (
SELECT
monday, tuesday, wednesday, thursday, friday, saturday, sunday
FROM organization_working_days
WHERE organization_id = (SELECT org_id FROM params)
LIMIT 1
),
formatted_days AS (
SELECT
d.date,
TO_CHAR(d.date, 'Dy') AS day_name,
EXTRACT(DAY FROM d.date) AS day,
-- Dynamically check if the day is a weekend based on the organization's settings
CASE
WHEN EXTRACT(DOW FROM d.date) = 0 THEN NOT (SELECT sunday FROM org_working_days)
WHEN EXTRACT(DOW FROM d.date) = 1 THEN NOT (SELECT monday FROM org_working_days)
WHEN EXTRACT(DOW FROM d.date) = 2 THEN NOT (SELECT tuesday FROM org_working_days)
WHEN EXTRACT(DOW FROM d.date) = 3 THEN NOT (SELECT wednesday FROM org_working_days)
WHEN EXTRACT(DOW FROM d.date) = 4 THEN NOT (SELECT thursday FROM org_working_days)
WHEN EXTRACT(DOW FROM d.date) = 5 THEN NOT (SELECT friday FROM org_working_days)
WHEN EXTRACT(DOW FROM d.date) = 6 THEN NOT (SELECT saturday FROM org_working_days)
END AS is_weekend,
CASE WHEN d.date = (SELECT today FROM params) THEN TRUE ELSE FALSE END AS is_today
FROM days d
),
grouped_by_month AS (
SELECT
TO_CHAR(date, 'Mon YYYY') AS month_name,
jsonb_agg(
jsonb_build_object(
'day', day,
'name', day_name,
'isWeekend', is_weekend,
'isToday', is_today
) ORDER BY date
) AS days
FROM formatted_days
GROUP BY month_name
)
SELECT jsonb_build_object(
'date_data', jsonb_agg(
jsonb_build_object(
'month', month_name,
'weeks', '[]'::JSONB, -- Placeholder for weeks data
'days', days
) ORDER BY month_name
),
'chart_start', (SELECT start_date FROM params),
'chart_end', (SELECT end_date FROM params)
) AS result_json
FROM grouped_by_month;`;
const results = await db.query(getDataq, [date, req.user?.owner_id]);
const [data] = results.rows;
return res.status(200).send(new ServerResponse(true, data.result_json));
}
return res.status(200).send(new ServerResponse(true, []));
}
@HandleExceptions()
public static async getOrganizationMembers(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
const getDataq = `SELECT DISTINCT ON (users.email)
team_members.id AS team_member_id,
users.id AS id,
users.name AS name,
users.email AS email,
'[]'::JSONB AS projects
FROM team_members
INNER JOIN users ON users.id = team_members.user_id
WHERE team_members.team_id IN (
SELECT id FROM teams
WHERE organization_id IN (
SELECT id FROM organizations
WHERE user_id = $1
LIMIT 1
)
)
ORDER BY users.email ASC, users.name ASC;`;
const results = await db.query(getDataq, [req.user?.owner_id]);
return res.status(200).send(new ServerResponse(true, results.rows));
}
@HandleExceptions()
public static async getOrganizationMemberProjects(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
const { id } = req.params;
const getDataq = `WITH project_dates AS (
SELECT
pm.project_id,
MIN(pm.allocated_from) AS start_date,
MAX(pm.allocated_to) AS end_date,
MAX(pm.seconds_per_day) / 3600 AS hours_per_day, -- Convert max seconds per day to hours per day
(
-- Calculate total working days between start and end dates
SELECT COUNT(*)
FROM generate_series(MIN(pm.allocated_from), MAX(pm.allocated_to), '1 day'::interval) AS day
JOIN public.organization_working_days owd ON owd.organization_id = t.organization_id
WHERE
(EXTRACT(ISODOW FROM day) = 1 AND owd.monday = true) OR
(EXTRACT(ISODOW FROM day) = 2 AND owd.tuesday = true) OR
(EXTRACT(ISODOW FROM day) = 3 AND owd.wednesday = true) OR
(EXTRACT(ISODOW FROM day) = 4 AND owd.thursday = true) OR
(EXTRACT(ISODOW FROM day) = 5 AND owd.friday = true) OR
(EXTRACT(ISODOW FROM day) = 6 AND owd.saturday = true) OR
(EXTRACT(ISODOW FROM day) = 7 AND owd.sunday = true)
) * (MAX(pm.seconds_per_day) / 3600) AS total_hours -- Multiply by hours per day
FROM public.project_member_allocations pm
JOIN public.projects p ON pm.project_id = p.id
JOIN public.teams t ON p.team_id = t.id
GROUP BY pm.project_id, t.organization_id
),
projects_with_offsets AS (
SELECT
p.name AS project_name,
p.id AS project_id,
COALESCE(pd.hours_per_day, 0) AS hours_per_day, -- Default to 8 if not available in project_member_allocations
COALESCE(pd.total_hours, 0) AS total_hours, -- Calculated total hours based on working days
pd.start_date,
pd.end_date,
p.team_id,
tm.user_id,
-- Calculate indicator_offset dynamically: days difference from earliest project start date * 75px
COALESCE(
(DATE_PART('day', pd.start_date - MIN(pd.start_date) OVER ())) * 75,
0
) AS indicator_offset,
-- Calculate indicator_width as the number of days * 75 pixels per day
COALESCE((DATE_PART('day', pd.end_date - pd.start_date) + 1) * 75, 75) AS indicator_width, -- Fallback to 75 if no dates exist
75 AS min_width -- 75px minimum width for a 1-day project
FROM public.projects p
LEFT JOIN project_dates pd ON p.id = pd.project_id
JOIN public.team_members tm ON tm.team_id = p.team_id
JOIN public.teams t ON p.team_id = t.id
WHERE tm.user_id = $2
AND tm.team_id = $1
ORDER BY pd.start_date, pd.end_date -- Order by start and end date
)
SELECT jsonb_agg(jsonb_build_object(
'name', project_name,
'id', project_id,
'hours_per_day', hours_per_day,
'total_hours', total_hours,
'date_union', jsonb_build_object(
'start', start_date::DATE,
'end', end_date::DATE
),
'indicator_offset', indicator_offset,
'indicator_width', indicator_width,
'tasks', '[]'::jsonb, -- Empty tasks array for now,
'default_values', jsonb_build_object(
'allocated_from', start_date::DATE,
'allocated_to', end_date::DATE,
'seconds_per_day', hours_per_day,
'total_seconds', total_hours
)
)) AS projects
FROM projects_with_offsets;`;
const results = await db.query(getDataq, [req.user?.team_id, id]);
const [data] = results.rows;
return res.status(200).send(new ServerResponse(true, { projects: data.projects, id }));
}
@HandleExceptions()
public static async createSchedule(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
const { allocated_from, allocated_to, project_id, team_member_id, seconds_per_day } = req.body;
const fromFormat = moment(allocated_from).format("YYYY-MM-DD");
const toFormat = moment(allocated_to).format("YYYY-MM-DD");
const getDataq1 = `
SELECT id
FROM project_member_allocations
WHERE project_id = $1
AND team_member_id = $2
AND (
-- Case 1: The given range starts inside an existing range
($3 BETWEEN allocated_from AND allocated_to)
OR
-- Case 2: The given range ends inside an existing range
($4 BETWEEN allocated_from AND allocated_to)
OR
-- Case 3: The given range fully covers an existing range
(allocated_from BETWEEN $3 AND $4 AND allocated_to BETWEEN $3 AND $4)
OR
-- Case 4: The existing range fully covers the given range
(allocated_from <= $3 AND allocated_to >= $4)
);`;
const results1 = await db.query(getDataq1, [project_id, team_member_id, fromFormat, toFormat]);
const [data] = results1.rows;
if (data) {
return res.status(200).send(new ServerResponse(false, null, "Allocation already exists!"));
}
const getDataq = `INSERT INTO public.project_member_allocations(
project_id, team_member_id, allocated_from, allocated_to, seconds_per_day)
VALUES ($1, $2, $3, $4, $5);`;
const results = await db.query(getDataq, [project_id, team_member_id, allocated_from, allocated_to, Number(seconds_per_day) * 60 * 60]);
return res.status(200).send(new ServerResponse(true, null, "Allocated successfully!"));
}
}

View File

@@ -52,6 +52,83 @@ export default class ScheduleControllerV2 extends ScheduleTasksControllerBase {
private static GLOBAL_START_DATE = moment().format("YYYY-MM-DD");
private static GLOBAL_END_DATE = moment().format("YYYY-MM-DD");
// Migrate data
@HandleExceptions()
public static async migrate(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
const getDataq = `SELECT p.id,
(SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(rec))), '[]'::JSON)
FROM (SELECT tmiv.team_member_id,
tmiv.user_id,
LEAST(
(SELECT MIN(LEAST(start_date, end_date)) AS start_date
FROM tasks
INNER JOIN tasks_assignees ta ON tasks.id = ta.task_id
WHERE archived IS FALSE
AND project_id = p.id
AND ta.team_member_id = tmiv.team_member_id),
(SELECT MIN(twl.created_at - INTERVAL '1 second' * twl.time_spent) AS ll_start_date
FROM task_work_log twl
INNER JOIN tasks t ON twl.task_id = t.id AND t.archived IS FALSE
WHERE t.project_id = p.id
AND twl.user_id = tmiv.user_id)
) AS lowest_date,
GREATEST(
(SELECT MAX(GREATEST(start_date, end_date)) AS end_date
FROM tasks
INNER JOIN tasks_assignees ta ON tasks.id = ta.task_id
WHERE archived IS FALSE
AND project_id = p.id
AND ta.team_member_id = tmiv.team_member_id),
(SELECT MAX(twl.created_at - INTERVAL '1 second' * twl.time_spent) AS ll_end_date
FROM task_work_log twl
INNER JOIN tasks t ON twl.task_id = t.id AND t.archived IS FALSE
WHERE t.project_id = p.id
AND twl.user_id = tmiv.user_id)
) AS greatest_date
FROM project_members pm
INNER JOIN team_member_info_view tmiv
ON pm.team_member_id = tmiv.team_member_id
WHERE project_id = p.id) rec) AS members
FROM projects p
WHERE team_id IS NOT NULL
AND p.id NOT IN (SELECT project_id FROM archived_projects)`;
const projectMembersResults = await db.query(getDataq);
const projectMemberData = projectMembersResults.rows;
const arrayToInsert = [];
for (const data of projectMemberData) {
if (data.members.length) {
for (const member of data.members) {
const body = {
project_id: data.id,
team_member_id: member.team_member_id,
allocated_from: member.lowest_date ? member.lowest_date : null,
allocated_to: member.greatest_date ? member.greatest_date : null
};
if (body.allocated_from && body.allocated_to) arrayToInsert.push(body);
}
}
}
const insertArray = JSON.stringify(arrayToInsert);
const insertFunctionCall = `SELECT migrate_member_allocations($1)`;
await db.query(insertFunctionCall, [insertArray]);
return res.status(200).send(new ServerResponse(true, ""));
}
private static async getFirstLastDates(teamId: string, userId: string) {
const q = `SELECT MIN(LEAST(allocated_from, allocated_to)) AS start_date,
MAX(GREATEST(allocated_from, allocated_to)) AS end_date,

View File

@@ -5,7 +5,7 @@ import {IWorkLenzResponse} from "../interfaces/worklenz-response";
import db from "../config/db";
import {ServerResponse} from "../models/server-response";
import {PriorityColorCodes, TASK_PRIORITY_COLOR_ALPHA, TASK_STATUS_COLOR_ALPHA} from "../shared/constants";
import {PriorityColorCodes, PriorityColorCodesDark, TASK_PRIORITY_COLOR_ALPHA, TASK_STATUS_COLOR_ALPHA} from "../shared/constants";
import {getColor} from "../shared/utils";
import WorklenzControllerBase from "./worklenz-controller-base";
import HandleExceptions from "../decorators/handle-exceptions";
@@ -33,6 +33,7 @@ export default class SubTasksController extends WorklenzControllerBase {
(ts.name) AS status_name,
TRUE AS is_sub_task,
(tsc.color_code) AS status_color,
(tsc.color_code_dark) AS status_color_dark,
(SELECT name FROM projects WHERE id = t.project_id) AS project_name,
(SELECT value FROM task_priorities WHERE id = t.priority_id) AS priority_value,
total_minutes,
@@ -46,11 +47,12 @@ export default class SubTasksController extends WorklenzControllerBase {
WHERE task_id = t.id
ORDER BY name) r) AS labels,
(SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(rec))), '[]'::JSON)
FROM (SELECT task_statuses.id, task_statuses.name, stsc.color_code
FROM (SELECT task_statuses.id, task_statuses.name, stsc.color_code, stsc.color_code_dark
FROM task_statuses
INNER JOIN sys_task_status_categories stsc ON task_statuses.category_id = stsc.id
WHERE project_id = t.project_id
ORDER BY task_statuses.name) rec) AS statuses
ORDER BY task_statuses.name) rec) AS statuses,
t.completed_at
FROM tasks t
INNER JOIN task_statuses ts ON ts.id = t.status_id
INNER JOIN task_priorities tp ON tp.id = t.priority_id
@@ -62,6 +64,7 @@ export default class SubTasksController extends WorklenzControllerBase {
for (const task of result.rows) {
task.priority_color = PriorityColorCodes[task.priority_value] || null;
task.priority_color_dark = PriorityColorCodesDark[task.priority_value] || null;
task.time_spent = {hours: Math.floor(task.total_minutes_spent / 60), minutes: task.total_minutes_spent % 60};
task.time_spent_string = `${task.time_spent.hours}h ${task.time_spent.minutes}m`;
@@ -72,6 +75,7 @@ export default class SubTasksController extends WorklenzControllerBase {
task.labels = this.createTagList(task.labels, 2);
task.status_color = task.status_color + TASK_STATUS_COLOR_ALPHA;
task.status_color_dark = task.status_color_dark + TASK_STATUS_COLOR_ALPHA;
task.priority_color = task.priority_color + TASK_PRIORITY_COLOR_ALPHA;
}

View File

@@ -6,11 +6,13 @@ import { ServerResponse } from "../models/server-response";
import WorklenzControllerBase from "./worklenz-controller-base";
import HandleExceptions from "../decorators/handle-exceptions";
import { NotificationsService } from "../services/notifications/notifications.service";
import { log_error } from "../shared/utils";
import { HTML_TAG_REGEXP } from "../shared/constants";
import { humanFileSize, log_error, megabytesToBytes } from "../shared/utils";
import { HTML_TAG_REGEXP, S3_URL } from "../shared/constants";
import { getBaseUrl } from "../cron_jobs/helpers";
import { ICommentEmailNotification } from "../interfaces/comment-email-notification";
import { sendTaskComment } from "../shared/email-notifications";
import { getRootDir, uploadBase64, getKey, getTaskAttachmentKey, createPresignedUrlWithClient } from "../shared/s3";
import { getFreePlanSettings, getUsedStorage } from "../shared/paddle-utils";
interface ITaskAssignee {
team_member_id: string;
@@ -99,11 +101,134 @@ export default class TaskCommentsController extends WorklenzControllerBase {
public static async create(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
req.body.user_id = req.user?.id;
req.body.team_id = req.user?.team_id;
const {mentions} = req.body;
const { mentions, attachments, task_id } = req.body;
const url = `${S3_URL}/${getRootDir()}`;
let commentContent = req.body.content;
if (mentions.length > 0) {
commentContent = await this.replaceContent(commentContent, mentions);
commentContent = this.replaceContent(commentContent, mentions);
}
req.body.content = commentContent;
const q = `SELECT create_task_comment($1) AS comment;`;
const result = await db.query(q, [JSON.stringify(req.body)]);
const [data] = result.rows;
const response = data.comment;
const commentId = response.id;
if (attachments.length !== 0) {
for (const attachment of attachments) {
const q = `
INSERT INTO task_comment_attachments (name, type, size, task_id, comment_id, team_id, project_id)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id, name, type, task_id, comment_id, created_at,
CONCAT($8::TEXT, '/', team_id, '/', project_id, '/', task_id, '/', comment_id, '/', id, '.', type) AS url;
`;
const result = await db.query(q, [
attachment.file_name,
attachment.file_name.split(".").pop(),
attachment.size,
task_id,
commentId,
req.user?.team_id,
attachment.project_id,
url
]);
const [data] = result.rows;
const s3Url = await uploadBase64(attachment.file, getTaskAttachmentKey(req.user?.team_id as string, attachment.project_id, task_id, commentId, data.id, data.type));
if (!data?.id || !s3Url)
return res.status(200).send(new ServerResponse(false, null, "Attachment upload failed"));
}
}
const mentionMessage = `<b>${req.user?.name}</b> has mentioned you in a comment on <b>${response.task_name}</b> (${response.team_name})`;
// const mentions = [...new Set(req.body.mentions || [])] as string[]; // remove duplicates
const assignees = await getAssignees(req.body.task_id);
const commentMessage = `<b>${req.user?.name}</b> added a comment on <b>${response.task_name}</b> (${response.team_name})`;
for (const member of assignees || []) {
if (member.user_id && member.user_id === req.user?.id) continue;
void NotificationsService.createNotification({
userId: member.user_id,
teamId: req.user?.team_id as string,
socketId: member.socket_id,
message: commentMessage,
taskId: req.body.task_id,
projectId: response.project_id
});
if (member.email_notifications_enabled)
await this.sendMail({
message: commentMessage,
receiverEmail: member.email,
receiverName: member.name,
content: req.body.content,
commentId: response.id,
projectId: response.project_id,
taskId: req.body.task_id,
teamName: response.team_name,
projectName: response.project_name,
taskName: response.task_name
});
}
const senderUserId = req.user?.id as string;
for (const mention of mentions) {
if (mention) {
const member = await this.getUserDataByTeamMemberId(senderUserId, mention.team_member_id, response.project_id);
if (member) {
NotificationsService.sendNotification({
team: member.team,
receiver_socket_id: member.socket_id,
message: mentionMessage,
task_id: req.body.task_id,
project_id: response.project_id,
project: member.project,
project_color: member.project_color,
team_id: req.user?.team_id as string
});
if (member.email_notifications_enabled)
await this.sendMail({
message: mentionMessage,
receiverEmail: member.email,
receiverName: member.user_name,
content: req.body.content,
commentId: response.id,
projectId: response.project_id,
taskId: req.body.task_id,
teamName: response.team_name,
projectName: response.project_name,
taskName: response.task_name
});
}
}
}
return res.status(200).send(new ServerResponse(true, data.comment));
}
@HandleExceptions()
public static async update(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
req.body.user_id = req.user?.id;
req.body.team_id = req.user?.team_id;
const { mentions, comment_id } = req.body;
let commentContent = req.body.content;
if (mentions.length > 0) {
commentContent = await this.replaceContent(commentContent, mentions);
}
req.body.content = commentContent;
@@ -210,46 +335,90 @@ export default class TaskCommentsController extends WorklenzControllerBase {
@HandleExceptions()
public static async getByTaskId(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
const q = `
SELECT task_comments.id,
tc.text_content AS content,
task_comments.user_id,
task_comments.team_member_id,
(SELECT name FROM team_member_info_view WHERE team_member_info_view.team_member_id = tm.id) AS member_name,
u.avatar_url,
task_comments.created_at,
(SELECT COALESCE(JSON_AGG(rec), '[]'::JSON)
FROM (SELECT tmiv.name AS user_name,
tmiv.email AS user_email
FROM task_comment_mentions tcm
LEFT JOIN team_member_info_view tmiv ON tcm.informed_by = tmiv.team_member_id
WHERE tcm.comment_id = task_comments.id) rec) AS mentions
FROM task_comments
INNER JOIN task_comment_contents tc ON task_comments.id = tc.comment_id
INNER JOIN team_members tm ON task_comments.team_member_id = tm.id
LEFT JOIN users u ON tm.user_id = u.id
WHERE task_comments.task_id = $1
ORDER BY task_comments.created_at DESC;
`;
const result = await db.query(q, [req.params.id]); // task id
const result = await TaskCommentsController.getTaskComments(req.params.id); // task id
return res.status(200).send(new ServerResponse(true, result.rows));
}
private static async getTaskComments(taskId: string) {
const url = `${S3_URL}/${getRootDir()}`;
const q = `SELECT task_comments.id,
tc.text_content AS content,
task_comments.user_id,
task_comments.team_member_id,
(SELECT name FROM team_member_info_view WHERE team_member_info_view.team_member_id = tm.id) AS member_name,
u.avatar_url,
task_comments.created_at,
(SELECT COALESCE(JSON_AGG(rec), '[]'::JSON)
FROM (SELECT tmiv.name AS user_name,
tmiv.email AS user_email
FROM task_comment_mentions tcm
LEFT JOIN team_member_info_view tmiv ON tcm.informed_by = tmiv.team_member_id
WHERE tcm.comment_id = task_comments.id) rec) AS mentions,
(SELECT JSON_BUILD_OBJECT(
'likes',
JSON_BUILD_OBJECT(
'count', (SELECT COUNT(*)
FROM task_comment_reactions tcr
WHERE tcr.comment_id = task_comments.id
AND reaction_type = 'like'),
'liked_members', COALESCE(
(SELECT JSON_AGG(tmiv.name)
FROM task_comment_reactions tcr
JOIN team_member_info_view tmiv ON tcr.team_member_id = tmiv.team_member_id
WHERE tcr.comment_id = task_comments.id
AND tcr.reaction_type = 'like'),
'[]'::JSON
),
'liked_member_ids', COALESCE(
(SELECT JSON_AGG(tmiv.team_member_id)
FROM task_comment_reactions tcr
JOIN team_member_info_view tmiv ON tcr.team_member_id = tmiv.team_member_id
WHERE tcr.comment_id = task_comments.id
AND tcr.reaction_type = 'like'),
'[]'::JSON
)
)
)) AS reactions,
(SELECT COALESCE(JSON_AGG(rec), '[]'::JSON)
FROM (SELECT id, created_at, name, size, type, (CONCAT('/', team_id, '/', project_id, '/', task_id, '/', comment_id, '/', id, '.', type)) AS url
FROM task_comment_attachments tca
WHERE tca.comment_id = task_comments.id) rec) AS attachments
FROM task_comments
LEFT JOIN task_comment_contents tc ON task_comments.id = tc.comment_id
INNER JOIN team_members tm ON task_comments.team_member_id = tm.id
LEFT JOIN users u ON tm.user_id = u.id
WHERE task_comments.task_id = $1
ORDER BY task_comments.created_at;`;
const result = await db.query(q, [taskId]); // task id
for (const comment of result.rows) {
if (!comment.content) comment.content = "";
comment.rawContent = await comment.content;
comment.content = await comment.content.replace(/\n/g, "</br>");
const {mentions} = comment;
comment.edit = false;
const { mentions } = comment;
if (mentions.length > 0) {
const placeHolders = comment.content.match(/{\d+}/g);
if (placeHolders) {
placeHolders.forEach((placeHolder: { match: (arg0: RegExp) => string[]; }) => {
const index = parseInt(placeHolder.match(/\d+/)[0]);
if (index >= 0 && index < comment.mentions.length) {
comment.content = comment.content.replace(placeHolder, `<span class="mentions"> @${comment.mentions[index].user_name} </span>`);
}
const index = parseInt(placeHolder.match(/\d+/)[0]);
if (index >= 0 && index < comment.mentions.length) {
comment.rawContent = comment.rawContent.replace(placeHolder, `@${comment.mentions[index].user_name}`);
comment.content = comment.content.replace(placeHolder, `<span class="mentions"> @${comment.mentions[index].user_name} </span>`);
}
});
}
}
for (const attachment of comment.attachments) {
attachment.size = humanFileSize(attachment.size);
attachment.url = url + attachment.url;
}
}
return res.status(200).send(new ServerResponse(true, result.rows));
return result;
}
@HandleExceptions()
@@ -262,4 +431,186 @@ export default class TaskCommentsController extends WorklenzControllerBase {
const result = await db.query(q, [req.params.id, req.params.taskId, req.user?.id || null]);
return res.status(200).send(new ServerResponse(true, result.rows));
}
@HandleExceptions()
public static async deleteAttachmentById(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
const q = `DELETE
FROM task_comment_attachments
WHERE id = $1;`;
const result = await db.query(q, [req.params.id]);
return res.status(200).send(new ServerResponse(true, result.rows));
}
private static async checkIfAlreadyExists(commentId: string, teamMemberId: string | undefined, reaction_type: string) {
if (!teamMemberId) return;
try {
const q = `SELECT EXISTS(SELECT 1 FROM task_comment_reactions WHERE comment_id = $1 AND team_member_id = $2 AND reaction_type = $3)`;
const result = await db.query(q, [commentId, teamMemberId, reaction_type]);
const [data] = result.rows;
return data.exists;
} catch (error) {
log_error(error);
}
}
private static async getTaskCommentData(commentId: string) {
if (!commentId) return;
try {
const q = `SELECT tc.user_id,
t.project_id,
t.name AS task_name,
(SELECT team_id FROM projects p WHERE p.id = t.project_id) AS team_id,
(SELECT name FROM teams te WHERE id = (SELECT team_id FROM projects p WHERE p.id = t.project_id)) AS team_name,
(SELECT u.socket_id FROM users u WHERE u.id = tc.user_id) AS socket_id,
(SELECT name FROM team_member_info_view tmiv WHERE tmiv.team_member_id = tcr.team_member_id) AS reactor_name
FROM task_comments tc
LEFT JOIN tasks t ON t.id = tc.task_id
LEFT JOIN task_comment_reactions tcr ON tc.id = tcr.comment_id
WHERE tc.id = $1;`;
const result = await db.query(q, [commentId]);
const [data] = result.rows;
return data;
} catch (error) {
log_error(error);
}
}
@HandleExceptions()
public static async updateReaction(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
const { id } = req.params;
const { reaction_type, task_id } = req.query;
const exists = await this.checkIfAlreadyExists(id, req.user?.team_member_id, reaction_type as string);
if (exists) {
const deleteQ = `DELETE FROM task_comment_reactions WHERE comment_id = $1 AND team_member_id = $2;`;
await db.query(deleteQ, [id, req.user?.team_member_id]);
} else {
const q = `INSERT INTO task_comment_reactions (comment_id, user_id, team_member_id) VALUES ($1, $2, $3);`;
await db.query(q, [id, req.user?.id, req.user?.team_member_id]);
const getTaskCommentData = await TaskCommentsController.getTaskCommentData(id);
const commentMessage = `<b>${getTaskCommentData.reactor_name}</b> liked your comment on <b>${getTaskCommentData.task_name}</b> (${getTaskCommentData.team_name})`;
if (getTaskCommentData && getTaskCommentData.user_id !== req.user?.id) {
void NotificationsService.createNotification({
userId: getTaskCommentData.user_id,
teamId: req.user?.team_id as string,
socketId: getTaskCommentData.socket_id,
message: commentMessage,
taskId: req.body.task_id,
projectId: getTaskCommentData.project_id
});
}
}
const result = await TaskCommentsController.getTaskComments(task_id as string);
return res.status(200).send(new ServerResponse(true, result.rows));
}
@HandleExceptions()
public static async createAttachment(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
req.body.user_id = req.user?.id;
req.body.team_id = req.user?.team_id;
const { attachments, task_id } = req.body;
const q = `INSERT INTO task_comments (user_id, team_member_id, task_id)
VALUES ($1, (SELECT id
FROM team_members
WHERE user_id = $1
AND team_id = $2::UUID), $3)
RETURNING id;`;
const result = await db.query(q, [req.user?.id, req.user?.team_id, task_id]);
const [data] = result.rows;
const commentId = data.id;
const url = `${S3_URL}/${getRootDir()}`;
for (const attachment of attachments) {
if (req.user?.subscription_status === "free" && req.user?.owner_id) {
const limits = await getFreePlanSettings();
const usedStorage = await getUsedStorage(req.user?.owner_id);
if ((parseInt(usedStorage) + attachment.size) > megabytesToBytes(parseInt(limits.free_tier_storage))) {
return res.status(200).send(new ServerResponse(false, [], `Sorry, the free plan cannot exceed ${limits.free_tier_storage}MB of storage.`));
}
}
const q = `
INSERT INTO task_comment_attachments (name, type, size, task_id, comment_id, team_id, project_id)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id, name, type, task_id, comment_id, created_at,
CONCAT($8::TEXT, '/', team_id, '/', project_id, '/', task_id, '/', comment_id, '/', id, '.', type) AS url;
`;
const result = await db.query(q, [
attachment.file_name,
attachment.size,
attachment.file_name.split(".").pop(),
task_id,
commentId,
req.user?.team_id,
attachment.project_id,
url
]);
const [data] = result.rows;
const s3Url = await uploadBase64(attachment.file, getTaskAttachmentKey(req.user?.team_id as string, attachment.project_id, task_id, commentId, data.id, data.type));
if (!data?.id || !s3Url)
return res.status(200).send(new ServerResponse(false, null, "Attachment upload failed"));
}
const assignees = await getAssignees(task_id);
const commentMessage = `<b>${req.user?.name}</b> added a new attachment as a comment on <b>${commentId.task_name}</b> (${commentId.team_name})`;
for (const member of assignees || []) {
if (member.user_id && member.user_id === req.user?.id) continue;
void NotificationsService.createNotification({
userId: member.user_id,
teamId: req.user?.team_id as string,
socketId: member.socket_id,
message: commentMessage,
taskId: task_id,
projectId: commentId.project_id
});
if (member.email_notifications_enabled)
await this.sendMail({
message: commentMessage,
receiverEmail: member.email,
receiverName: member.name,
content: req.body.content,
commentId: commentId.id,
projectId: commentId.project_id,
taskId: task_id,
teamName: commentId.team_name,
projectName: commentId.project_name,
taskName: commentId.task_name
});
}
return res.status(200).send(new ServerResponse(true, []));
}
@HandleExceptions()
public static async download(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
const q = `SELECT CONCAT($2::TEXT, '/', team_id, '/', project_id, '/', task_id, '/', comment_id, '/', id, '.', type) AS key
FROM task_comment_attachments
WHERE id = $1;`;
const result = await db.query(q, [req.query.id, getRootDir()]);
const [data] = result.rows;
if (data?.key) {
const url = await createPresignedUrlWithClient(data.key, req.query.file as string);
return res.status(200).send(new ServerResponse(true, url));
}
return res.status(200).send(new ServerResponse(true, null));
}
}

View File

@@ -0,0 +1,52 @@
import { IWorkLenzRequest } from "../interfaces/worklenz-request";
import { IWorkLenzResponse } from "../interfaces/worklenz-response";
import db from "../config/db";
import { ServerResponse } from "../models/server-response";
import WorklenzControllerBase from "./worklenz-controller-base";
import HandleExceptions from "../decorators/handle-exceptions";
export default class TaskdependenciesController extends WorklenzControllerBase {
@HandleExceptions({
raisedExceptions: {
"DEPENDENCY_EXISTS": `Task dependency already exists.`
}
})
public static async saveTaskDependency(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
const {task_id, related_task_id, dependency_type } = req.body;
const q = `SELECT insert_task_dependency($1, $2, $3);`;
const result = await db.query(q, [task_id, related_task_id, dependency_type]);
return res.status(200).send(new ServerResponse(true, result.rows));
}
@HandleExceptions()
public static async getTaskDependencies(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
const { id } = req.params;
const q = `SELECT
td.id,
t2.name AS task_name,
td.dependency_type,
CONCAT(p.key, '-', t2.task_no) AS task_key
FROM
task_dependencies td
LEFT JOIN
tasks t ON td.task_id = t.id
LEFT JOIN
tasks t2 ON td.related_task_id = t2.id
LEFT JOIN
projects p ON t.project_id = p.id
WHERE
td.task_id = $1;`;
const result = await db.query(q, [id]);
return res.status(200).send(new ServerResponse(true, result.rows));
}
public static async deleteById(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
const {id} = req.params;
const q = `DELETE FROM task_dependencies WHERE id = $1;`;
const result = await db.query(q, [id]);
return res.status(200).send(new ServerResponse(true, result.rows));
}
}

View File

@@ -32,8 +32,9 @@ export default class TaskListColumnsController extends WorklenzControllerBase {
const q = `UPDATE project_task_list_cols
SET pinned = $3
WHERE project_id = $1
AND key = $2;`;
AND key = $2 RETURNING *;`;
const result = await db.query(q, [req.params.id, req.body.key, !!req.body.pinned]);
return res.status(200).send(new ServerResponse(true, result.rows));
const [data] = result.rows;
return res.status(200).send(new ServerResponse(true, data));
}
}

View File

@@ -5,15 +5,17 @@ import db from "../config/db";
import {ServerResponse} from "../models/server-response";
import WorklenzControllerBase from "./worklenz-controller-base";
import HandleExceptions from "../decorators/handle-exceptions";
import {PriorityColorCodes} from "../shared/constants";
import {PriorityColorCodes, PriorityColorCodesDark} from "../shared/constants";
export default class TaskPrioritiesController extends WorklenzControllerBase {
@HandleExceptions()
public static async get(_req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
const q = `SELECT id, name, value From task_priorities ORDER BY value;`;
const result = await db.query(q, []);
for (const item of result.rows)
for (const item of result.rows) {
item.color_code = PriorityColorCodes[item.value] || PriorityColorCodes["0"];
item.color_code_dark = PriorityColorCodesDark[item.value] || PriorityColorCodesDark["0"];
}
return res.status(200).send(new ServerResponse(true, result.rows));
}

View File

@@ -0,0 +1,108 @@
import db from "../config/db";
import WorklenzControllerBase from "./worklenz-controller-base";
import HandleExceptions from "../decorators/handle-exceptions";
import { IWorkLenzRequest } from "../interfaces/worklenz-request";
import { IWorkLenzResponse } from "../interfaces/worklenz-response";
import { ServerResponse } from "../models/server-response";
import { calculateNextEndDate, log_error } from "../shared/utils";
export default class TaskRecurringController extends WorklenzControllerBase {
@HandleExceptions()
public static async getById(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
const { id } = req.params;
const q = `SELECT id,
schedule_type,
days_of_week,
date_of_month,
day_of_month,
week_of_month,
interval_days,
interval_weeks,
interval_months,
created_at
FROM task_recurring_schedules WHERE id = $1;`;
const result = await db.query(q, [id]);
const [data] = result.rows;
return res.status(200).send(new ServerResponse(true, data));
}
private static async insertTaskRecurringTemplate(taskId: string, scheduleId: string) {
const q = `SELECT create_recurring_task_template($1, $2);`;
await db.query(q, [taskId, scheduleId]);
}
@HandleExceptions()
public static async createTaskSchedule(taskId: string) {
const q = `INSERT INTO task_recurring_schedules (schedule_type) VALUES ('daily') RETURNING id, schedule_type;`;
const result = await db.query(q, []);
const [data] = result.rows;
const updateQ = `UPDATE tasks SET schedule_id = $1 WHERE id = $2;`;
await db.query(updateQ, [data.id, taskId]);
await TaskRecurringController.insertTaskRecurringTemplate(taskId, data.id);
return data;
}
@HandleExceptions()
public static async removeTaskSchedule(scheduleId: string) {
const deleteQ = `DELETE FROM task_recurring_schedules WHERE id = $1;`;
await db.query(deleteQ, [scheduleId]);
}
@HandleExceptions()
public static async updateSchedule(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
const { id } = req.params;
const { schedule_type, days_of_week, day_of_month, week_of_month, interval_days, interval_weeks, interval_months, date_of_month } = req.body;
const deleteQ = `UPDATE task_recurring_schedules
SET schedule_type = $1,
days_of_week = $2,
date_of_month = $3,
day_of_month = $4,
week_of_month = $5,
interval_days = $6,
interval_weeks = $7,
interval_months = $8
WHERE id = $9;`;
await db.query(deleteQ, [schedule_type, days_of_week, date_of_month, day_of_month, week_of_month, interval_days, interval_weeks, interval_months, id]);
return res.status(200).send(new ServerResponse(true, null));
}
// Function to create the next task in the recurring schedule
private static async createNextRecurringTask(scheduleId: string, lastTask: any, taskTemplate: any) {
try {
const q = "SELECT * FROM task_recurring_schedules WHERE id = $1";
const { rows: schedules } = await db.query(q, [scheduleId]);
if (schedules.length === 0) {
log_error("No schedule found");
return;
}
const [schedule] = schedules;
// Define the next start date based on the schedule
const nextStartDate = calculateNextEndDate(schedule, lastTask.start_date);
const result = await db.query(
`INSERT INTO tasks (name, start_date, end_date, priority_id, project_id, reporter_id, description, total_minutes, status_id, schedule_id)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) RETURNING id;`,
[
taskTemplate.name, nextStartDate, null, taskTemplate.priority_id,
lastTask.project_id, lastTask.reporter_id, taskTemplate.description,
0, taskTemplate.status_id, scheduleId
]
);
const [data] = result.rows;
log_error(`Next task created with id: ${data.id}`);
} catch (error) {
log_error("Error creating next recurring task:", error);
}
}
}

View File

@@ -54,7 +54,7 @@ export default class TaskStatusesController extends WorklenzControllerBase {
@HandleExceptions()
public static async getCategories(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
const q = `SELECT id, name, color_code, description
const q = `SELECT id, name, color_code, color_code_dark, description
FROM sys_task_status_categories
ORDER BY index;`;
const result = await db.query(q, []);
@@ -73,7 +73,7 @@ export default class TaskStatusesController extends WorklenzControllerBase {
@HandleExceptions()
public static async getById(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
const q = `
SELECT task_statuses.id, task_statuses.name, stsc.color_code
SELECT task_statuses.id, task_statuses.name, stsc.color_code, stsc.color_code_dark
FROM task_statuses
INNER JOIN sys_task_status_categories stsc ON task_statuses.category_id = stsc.id
WHERE task_statuses.id = $1
@@ -113,7 +113,7 @@ export default class TaskStatusesController extends WorklenzControllerBase {
category_id = COALESCE($4, (SELECT id FROM sys_task_status_categories WHERE is_todo IS TRUE))
WHERE id = $1
AND project_id = $3
RETURNING (SELECT color_code FROM sys_task_status_categories WHERE id = task_statuses.category_id);
RETURNING (SELECT color_code FROM sys_task_status_categories WHERE id = task_statuses.category_id), (SELECT color_code_dark FROM sys_task_status_categories WHERE id = task_statuses.category_id);
`;
const result = await db.query(q, [req.params.id, req.body.name, req.body.project_id, req.body.category_id]);
const [data] = result.rows;

View File

@@ -234,4 +234,25 @@ export default class TaskWorklogController extends WorklenzControllerBase {
res.end();
});
}
@HandleExceptions()
public static async getAllRunningTimers(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
const q = `SELECT
tt.task_id,
tt.start_time,
t1.name AS task_name,
pr.id AS project_id,
pr.name AS project_name,
t1.parent_task_id,
t2.name AS parent_task_name
FROM task_timers tt
LEFT JOIN public.tasks t1 ON tt.task_id = t1.id
LEFT JOIN public.tasks t2 ON t1.parent_task_id = t2.id -- Optimized join for parent task name
INNER JOIN projects pr ON t1.project_id = pr.id -- INNER JOIN ensures project-team match
WHERE tt.user_id = $1
AND pr.team_id = $2;`;
const params = [req.user?.id, req.user?.team_id];
const result = await db.query(q, params);
return res.status(200).send(new ServerResponse(true, result.rows));
}
}

View File

@@ -73,8 +73,8 @@ export default class TasksControllerBase extends WorklenzControllerBase {
if (task.timer_start_time)
task.timer_start_time = moment(task.timer_start_time).valueOf();
const totalCompleted = +task.completed_sub_tasks + +task.parent_task_completed;
const totalTasks = +task.sub_tasks_count + 1; // +1 for parent
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);
task.completed_count = totalCompleted;
task.total_tasks_count = totalTasks;

View File

@@ -1,18 +1,19 @@
import {ParsedQs} from "qs";
import { ParsedQs } from "qs";
import db from "../config/db";
import HandleExceptions from "../decorators/handle-exceptions";
import {IWorkLenzRequest} from "../interfaces/worklenz-request";
import {IWorkLenzResponse} from "../interfaces/worklenz-response";
import {ServerResponse} from "../models/server-response";
import {TASK_PRIORITY_COLOR_ALPHA, TASK_STATUS_COLOR_ALPHA, UNMAPPED} from "../shared/constants";
import {getColor} from "../shared/utils";
import TasksControllerBase, {GroupBy, ITaskGroup} from "./tasks-controller-base";
import { IWorkLenzRequest } from "../interfaces/worklenz-request";
import { IWorkLenzResponse } from "../interfaces/worklenz-response";
import { ServerResponse } from "../models/server-response";
import { TASK_PRIORITY_COLOR_ALPHA, TASK_STATUS_COLOR_ALPHA, UNMAPPED } from "../shared/constants";
import { getColor, log_error } from "../shared/utils";
import TasksControllerBase, { GroupBy, ITaskGroup } from "./tasks-controller-base";
export class TaskListGroup implements ITaskGroup {
name: string;
category_id: string | null;
color_code: string;
color_code_dark: string;
start_date?: string;
end_date?: string;
todo_progress: number;
@@ -26,6 +27,7 @@ export class TaskListGroup implements ITaskGroup {
this.start_date = group.start_date || null;
this.end_date = group.end_date || null;
this.color_code = group.color_code + TASK_STATUS_COLOR_ALPHA;
this.color_code_dark = group.color_code_dark;
this.todo_progress = 0;
this.doing_progress = 0;
this.done_progress = 0;
@@ -104,7 +106,7 @@ export default class TasksControllerV2 extends TasksControllerBase {
private static getQuery(userId: string, options: ParsedQs) {
const searchField = options.search ? "t.name" : "sort_order";
const {searchQuery, sortField} = TasksControllerV2.toPaginationOptions(options, searchField);
const { searchQuery, sortField } = TasksControllerV2.toPaginationOptions(options, searchField);
const isSubTasks = !!options.parent_task;
@@ -124,6 +126,33 @@ export default class TasksControllerV2 extends TasksControllerBase {
const filterByAssignee = TasksControllerV2.getFilterByAssignee(options.filterBy as string);
// Returns statuses of each task as a json array if filterBy === "member"
const statusesQuery = TasksControllerV2.getStatusesQuery(options.filterBy as string);
// Custom columns data query
const customColumnsQuery = options.customColumns
? `, (SELECT COALESCE(
jsonb_object_agg(
custom_cols.key,
custom_cols.value
),
'{}'::JSONB
)
FROM (
SELECT
cc.key,
CASE
WHEN ccv.text_value IS NOT NULL THEN to_jsonb(ccv.text_value)
WHEN ccv.number_value IS NOT NULL THEN to_jsonb(ccv.number_value)
WHEN ccv.boolean_value IS NOT NULL THEN to_jsonb(ccv.boolean_value)
WHEN ccv.date_value IS NOT NULL THEN to_jsonb(ccv.date_value)
WHEN ccv.json_value IS NOT NULL THEN ccv.json_value
ELSE NULL::JSONB
END AS value
FROM cc_column_values ccv
JOIN cc_custom_columns cc ON ccv.column_id = cc.id
WHERE ccv.task_id = t.id
) AS custom_cols
WHERE custom_cols.value IS NOT NULL) AS custom_column_values`
: "";
const archivedFilter = options.archived === "true" ? "archived IS TRUE" : "archived IS FALSE";
@@ -173,7 +202,7 @@ export default class TasksControllerV2 extends TasksControllerBase {
WHERE id = (SELECT phase_id FROM task_phase WHERE task_id = t.id)) AS phase_color_code,
(EXISTS(SELECT 1 FROM task_subscribers WHERE task_id = t.id)) AS has_subscribers,
(EXISTS(SELECT 1 FROM task_dependencies td WHERE td.task_id = t.id)) AS has_dependencies,
(SELECT start_time
FROM task_timers
WHERE task_id = t.id
@@ -183,6 +212,10 @@ export default class TasksControllerV2 extends TasksControllerBase {
FROM sys_task_status_categories
WHERE id = (SELECT category_id FROM task_statuses WHERE id = t.status_id)) AS status_color,
(SELECT color_code_dark
FROM sys_task_status_categories
WHERE id = (SELECT category_id FROM task_statuses WHERE id = t.status_id)) AS status_color_dark,
(SELECT COALESCE(ROW_TO_JSON(r), '{}'::JSON)
FROM (SELECT is_done, is_doing, is_todo
FROM sys_task_status_categories
@@ -209,7 +242,7 @@ export default class TasksControllerV2 extends TasksControllerBase {
(SELECT color_code FROM team_labels WHERE id = task_labels.label_id)
FROM task_labels
WHERE task_id = t.id) r) AS labels,
(SELECT is_completed(status_id, project_id)) AS is_complete,
(SELECT name FROM users WHERE id = t.reporter_id) AS reporter,
(SELECT id FROM task_priorities WHERE id = t.priority_id) AS priority,
(SELECT value FROM task_priorities WHERE id = t.priority_id) AS priority_value,
@@ -219,7 +252,9 @@ export default class TasksControllerV2 extends TasksControllerBase {
updated_at,
completed_at,
start_date,
END_DATE ${statusesQuery}
billable,
schedule_id,
END_DATE ${customColumnsQuery} ${statusesQuery}
FROM tasks t
WHERE ${filters} ${searchQuery}
ORDER BY ${sortFields}
@@ -235,6 +270,7 @@ export default class TasksControllerV2 extends TasksControllerBase {
SELECT id,
name,
(SELECT color_code FROM sys_task_status_categories WHERE id = task_statuses.category_id),
(SELECT color_code_dark FROM sys_task_status_categories WHERE id = task_statuses.category_id),
category_id
FROM task_statuses
WHERE project_id = $1
@@ -243,7 +279,7 @@ export default class TasksControllerV2 extends TasksControllerBase {
params = [projectId];
break;
case GroupBy.PRIORITY:
q = `SELECT id, name, color_code
q = `SELECT id, name, color_code, color_code_dark
FROM task_priorities
ORDER BY value DESC;`;
break;
@@ -261,7 +297,7 @@ export default class TasksControllerV2 extends TasksControllerBase {
break;
case GroupBy.PHASE:
q = `
SELECT id, name, color_code, start_date, end_date, sort_index
SELECT id, name, color_code, color_code AS color_code_dark, start_date, end_date, sort_index
FROM project_phases
WHERE project_id = $1
ORDER BY sort_index DESC;
@@ -281,6 +317,9 @@ export default class TasksControllerV2 extends TasksControllerBase {
public static async getList(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
const isSubTasks = !!req.query.parent_task;
const groupBy = (req.query.group || GroupBy.STATUS) as string;
// Add customColumns flag to query params
req.query.customColumns = "true";
const q = TasksControllerV2.getQuery(req.user?.id as string, req.query);
const params = isSubTasks ? [req.params.id || null, req.query.parent_task] : [req.params.id || null];
@@ -356,6 +395,10 @@ export default class TasksControllerV2 extends TasksControllerBase {
@HandleExceptions()
public static async getTasksOnly(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
const isSubTasks = !!req.query.parent_task;
// Add customColumns flag to query params
req.query.customColumns = "true";
const q = TasksControllerV2.getQuery(req.user?.id as string, req.query);
const params = isSubTasks ? [req.params.id || null, req.query.parent_task] : [req.params.id || null];
const result = await db.query(q, params);
@@ -393,7 +436,7 @@ export default class TasksControllerV2 extends TasksControllerBase {
@HandleExceptions()
public static async getNewKanbanTask(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
const {id} = req.params;
const { id } = req.params;
const result = await db.query("SELECT get_single_task($1) AS task;", [id]);
const [data] = result.rows;
const task = TasksControllerV2.updateTaskViewModel(data.task);
@@ -474,9 +517,211 @@ export default class TasksControllerV2 extends TasksControllerBase {
}
public static async getTasksByName(searchString: string, projectId: string, taskId: string) {
const q = `SELECT id AS value ,
name AS label,
CONCAT((SELECT key FROM projects WHERE id = t.project_id), '-', task_no) AS task_key
FROM tasks t
WHERE t.name ILIKE '%${searchString}%'
AND t.project_id = $1 AND t.id != $2
LIMIT 15;`;
const result = await db.query(q, [projectId, taskId]);
return result.rows;
}
@HandleExceptions()
public static async getSubscribers(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
const subscribers = await this.getTaskSubscribers(req.params.id);
return res.status(200).send(new ServerResponse(true, subscribers));
}
@HandleExceptions()
public static async searchTasks(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
const { projectId, taskId, searchQuery } = req.query;
const tasks = await this.getTasksByName(searchQuery as string, projectId as string, taskId as string);
return res.status(200).send(new ServerResponse(true, tasks));
}
@HandleExceptions()
public static async getTaskDependencyStatus(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
const { statusId, taskId } = req.query;
const canContinue = await TasksControllerV2.checkForCompletedDependencies(taskId as string, statusId as string);
return res.status(200).send(new ServerResponse(true, { can_continue: canContinue }));
}
@HandleExceptions()
public static async checkForCompletedDependencies(taskId: string, nextStatusId: string): Promise<IWorkLenzResponse> {
const q = `SELECT
CASE
WHEN EXISTS (
-- Check if the status id is not in the "done" category
SELECT 1
FROM task_statuses ts
WHERE ts.id = $2
AND ts.project_id = (SELECT project_id FROM tasks WHERE id = $1)
AND ts.category_id IN (
SELECT id FROM sys_task_status_categories WHERE is_done IS FALSE
)
) THEN TRUE -- If status is not in the "done" category, continue immediately (TRUE)
WHEN EXISTS (
-- Check if any dependent tasks are not completed
SELECT 1
FROM task_dependencies td
LEFT JOIN public.tasks t ON t.id = td.related_task_id
WHERE td.task_id = $1
AND t.status_id NOT IN (
SELECT id
FROM task_statuses ts
WHERE t.project_id = ts.project_id
AND ts.category_id IN (
SELECT id FROM sys_task_status_categories WHERE is_done IS TRUE
)
)
) THEN FALSE -- If there are incomplete dependent tasks, do not continue (FALSE)
ELSE TRUE -- Continue if no other conditions block the process
END AS can_continue;`;
const result = await db.query(q, [taskId, nextStatusId]);
const [data] = result.rows;
return data.can_continue;
}
public static async getTaskStatusColor(status_id: string) {
try {
const q = `SELECT color_code, color_code_dark
FROM sys_task_status_categories
WHERE id = (SELECT category_id FROM task_statuses WHERE id = $1)`;
const result = await db.query(q, [status_id]);
const [data] = result.rows;
return data;
} catch (e) {
log_error(e);
}
}
@HandleExceptions()
public static async assignLabelsToTask(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
const { id } = req.params;
const { labels }: { labels: string[] } = req.body;
labels.forEach(async (label: string) => {
const q = `SELECT add_or_remove_task_label($1, $2) AS labels;`;
await db.query(q, [id, label]);
});
return res.status(200).send(new ServerResponse(true, null, "Labels assigned successfully"));
}
/**
* Updates a custom column value for a task
* @param req The request object
* @param res The response object
*/
@HandleExceptions()
public static async updateCustomColumnValue(
req: IWorkLenzRequest,
res: IWorkLenzResponse
): Promise<IWorkLenzResponse> {
const { taskId } = req.params;
const { column_key, value, project_id } = req.body;
if (!taskId || !column_key || value === undefined || !project_id) {
return res.status(400).send(new ServerResponse(false, "Missing required parameters"));
}
// Get column information
const columnQuery = `
SELECT id, field_type
FROM cc_custom_columns
WHERE project_id = $1 AND key = $2
`;
const columnResult = await db.query(columnQuery, [project_id, column_key]);
if (columnResult.rowCount === 0) {
return res.status(404).send(new ServerResponse(false, "Custom column not found"));
}
const column = columnResult.rows[0];
const columnId = column.id;
const fieldType = column.field_type;
// Determine which value field to use based on the field_type
let textValue = null;
let numberValue = null;
let dateValue = null;
let booleanValue = null;
let jsonValue = null;
switch (fieldType) {
case "number":
numberValue = parseFloat(String(value));
break;
case "date":
dateValue = new Date(String(value));
break;
case "checkbox":
booleanValue = Boolean(value);
break;
case "people":
jsonValue = JSON.stringify(Array.isArray(value) ? value : [value]);
break;
default:
textValue = String(value);
}
// Check if a value already exists
const existingValueQuery = `
SELECT id
FROM cc_column_values
WHERE task_id = $1 AND column_id = $2
`;
const existingValueResult = await db.query(existingValueQuery, [taskId, columnId]);
if (existingValueResult.rowCount && existingValueResult.rowCount > 0) {
// Update existing value
const updateQuery = `
UPDATE cc_column_values
SET text_value = $1,
number_value = $2,
date_value = $3,
boolean_value = $4,
json_value = $5,
updated_at = NOW()
WHERE task_id = $6 AND column_id = $7
`;
await db.query(updateQuery, [
textValue,
numberValue,
dateValue,
booleanValue,
jsonValue,
taskId,
columnId
]);
} else {
// Insert new value
const insertQuery = `
INSERT INTO cc_column_values
(task_id, column_id, text_value, number_value, date_value, boolean_value, json_value, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, NOW(), NOW())
`;
await db.query(insertQuery, [
taskId,
columnId,
textValue,
numberValue,
dateValue,
booleanValue,
jsonValue
]);
}
return res.status(200).send(new ServerResponse(true, {
task_id: taskId,
column_key,
value
}));
}
}

View File

@@ -6,9 +6,9 @@ import { IWorkLenzResponse } from "../interfaces/worklenz-response";
import db from "../config/db";
import { ServerResponse } from "../models/server-response";
import { TASK_STATUS_COLOR_ALPHA } from "../shared/constants";
import { S3_URL, TASK_STATUS_COLOR_ALPHA } from "../shared/constants";
import { getDates, getMinMaxOfTaskDates, getMonthRange, getWeekRange } from "../shared/tasks-controller-utils";
import { getColor, getRandomColorCode, log_error, toMinutes } from "../shared/utils";
import { getColor, getRandomColorCode, humanFileSize, log_error, toMinutes } from "../shared/utils";
import WorklenzControllerBase from "./worklenz-controller-base";
import HandleExceptions from "../decorators/handle-exceptions";
import { NotificationsService } from "../services/notifications/notifications.service";
@@ -18,9 +18,9 @@ import TasksControllerV2 from "./tasks-controller-v2";
import { IO } from "../shared/io";
import { SocketEvents } from "../socket.io/events";
import TasksControllerBase from "./tasks-controller-base";
import { insertToActivityLogs, logStatusChange } from "../services/activity-logs/activity-logs.service";
import { forEach } from "lodash";
import { insertToActivityLogs } from "../services/activity-logs/activity-logs.service";
import { IActivityLog } from "../services/activity-logs/interfaces";
import { getKey, getRootDir, uploadBase64 } from "../shared/s3";
export default class TasksController extends TasksControllerBase {
private static notifyProjectUpdates(socketId: string, projectId: string) {
@@ -29,14 +29,54 @@ export default class TasksController extends TasksControllerBase {
.emit(SocketEvents.PROJECT_UPDATES_AVAILABLE.toString());
}
public static async uploadAttachment(attachments: any, teamId: string, userId: string) {
try {
const promises = attachments.map(async (attachment: any) => {
const { file, file_name, project_id, size } = attachment;
const type = file_name.split(".").pop();
const q = `
INSERT INTO task_attachments (name, task_id, team_id, project_id, uploaded_by, size, type)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id, name, size, type, created_at, CONCAT($8::TEXT, '/', team_id, '/', project_id, '/', id, '.', type) AS url;
`;
const result = await db.query(q, [
file_name,
null,
teamId,
project_id,
userId,
size,
type,
`${S3_URL}/${getRootDir()}`
]);
const [data] = result.rows;
await uploadBase64(file, getKey(teamId, project_id, data.id, data.type));
return data.id;
});
const attachmentIds = await Promise.all(promises);
return attachmentIds;
} catch (error) {
log_error(error);
}
}
@HandleExceptions()
public static async create(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
const userId = req.user?.id as string;
const teamId = req.user?.team_id as string;
if (req.body.attachments_raw) {
req.body.attachments = await this.uploadAttachment(req.body.attachments_raw, teamId, userId);
}
const q = `SELECT create_task($1) AS task;`;
const result = await db.query(q, [JSON.stringify(req.body)]);
const [data] = result.rows;
const userId = req.user?.id as string;
for (const member of data?.task.assignees || []) {
NotificationsService.createTaskUpdate(
"ASSIGN",
@@ -468,7 +508,7 @@ export default class TasksController extends TasksControllerBase {
TasksController.notifyProjectUpdates(req.user?.socket_id as string, req.query.project as string);
return res.status(200).send(new ServerResponse(true, data));
return res.status(200).send(new ServerResponse(true, { failed_tasks: data.task }));
}
@HandleExceptions()

View File

@@ -13,7 +13,9 @@ import { SocketEvents } from "../socket.io/events";
import WorklenzControllerBase from "./worklenz-controller-base";
import HandleExceptions from "../decorators/handle-exceptions";
import { formatDuration, getColor } from "../shared/utils";
import { TEAM_MEMBER_TREE_MAP_COLOR_ALPHA } from "../shared/constants";
import { statusExclude, TEAM_MEMBER_TREE_MAP_COLOR_ALPHA } from "../shared/constants";
import { checkTeamSubscriptionStatus } from "../shared/paddle-utils";
import { updateUsers } from "../shared/paddle-requests";
import { NotificationsService } from "../services/notifications/notifications.service";
export default class TeamMembersController extends WorklenzControllerBase {
@@ -80,6 +82,98 @@ export default class TeamMembersController extends WorklenzControllerBase {
return res.status(200).send(new ServerResponse(false, "Required fields are missing."));
}
/**
* Checks the subscription status of the team.
* @type {Object} subscriptionData - Object containing subscription information
*/
const subscriptionData = await checkTeamSubscriptionStatus(req.user?.team_id);
let incrementBy = 0;
// Handle self-hosted subscriptions differently
if (subscriptionData.subscription_type === 'SELF_HOSTED') {
// Check if users exist and add them if they don't
await Promise.all(req.body.emails.map(async (email: string) => {
const trimmedEmail = email.trim();
const userExists = await this.checkIfUserAlreadyExists(req.user?.owner_id as string, trimmedEmail);
if (!userExists) {
incrementBy = incrementBy + 1;
}
}));
// Create or invite new members
const newMembers = await this.createOrInviteMembers(req.body, req.user);
return res.status(200).send(new ServerResponse(true, newMembers, `Your teammates will get an email that gives them access to your team.`).withTitle("Invitations sent"));
}
/**
* Iterates through each email in the request body and checks if the user already exists.
* If the user doesn't exist, increments the counter.
* @param {string} email - Email address to check
*/
await Promise.all(req.body.emails.map(async (email: string) => {
const trimmedEmail = email.trim();
const userExists = await this.checkIfUserAlreadyExists(req.user?.owner_id as string, trimmedEmail);
const isUserActive = await this.checkIfUserActiveInOtherTeams(req.user?.owner_id as string, trimmedEmail);
if (!userExists || !isUserActive) {
incrementBy = incrementBy + 1;
}
}));
/**
* Checks various conditions to determine if the maximum number of lifetime users is exceeded.
* Sends a response if the limit is reached.
*/
if (
incrementBy > 0
&& subscriptionData.is_ltd
&& subscriptionData.current_count
&& ((parseInt(subscriptionData.current_count) + req.body.emails.length) > parseInt(subscriptionData.ltd_users))) {
return res.status(200).send(new ServerResponse(false, null, "Cannot exceed the maximum number of life time users."));
}
if (
subscriptionData.is_ltd
&& subscriptionData.current_count
&& ((parseInt(subscriptionData.current_count) + incrementBy) > parseInt(subscriptionData.ltd_users))) {
return res.status(200).send(new ServerResponse(false, null, "Cannot exceed the maximum number of life time users."));
}
/**
* Checks subscription details and updates the user count if applicable.
* Sends a response if there is an issue with the subscription.
*/
// if (!subscriptionData.is_credit && !subscriptionData.is_custom && subscriptionData.subscription_status === "active") {
// const response = await updateUsers(subscriptionData.subscription_id, (subscriptionData.quantity + incrementBy));
// if (!response.body.subscription_id) {
// return res.status(200).send(new ServerResponse(false, null, response.message || "Please check your subscription."));
// }
// }
if (!subscriptionData.is_credit && !subscriptionData.is_custom && subscriptionData.subscription_status === "active") {
const updatedCount = parseInt(subscriptionData.current_count) + incrementBy;
const requiredSeats = updatedCount - subscriptionData.quantity;
if (updatedCount > subscriptionData.quantity) {
const obj = {
seats_enough: false,
required_count: requiredSeats,
current_seat_amount: subscriptionData.quantity
};
return res.status(200).send(new ServerResponse(false, obj, null));
}
}
/**
* Checks if the subscription status is in the exclusion list.
* Sends a response if the status is excluded.
*/
if (statusExclude.includes(subscriptionData.subscription_status)) {
return res.status(200).send(new ServerResponse(false, null, "Unable to add user! Please check your subscription status."));
}
/**
* Creates or invites new members based on the request body and user information.
* Sends a response with the result.
@@ -93,12 +187,24 @@ export default class TeamMembersController extends WorklenzControllerBase {
req.query.field = ["is_owner", "active", "u.name", "u.email"];
req.query.order = "descend";
// Helper function to check for encoded components
function containsEncodedComponents(x: string) {
return decodeURI(x) !== decodeURIComponent(x);
}
// Decode search parameter if it contains encoded components
if (req.query.search && typeof req.query.search === 'string') {
if (containsEncodedComponents(req.query.search)) {
req.query.search = decodeURIComponent(req.query.search);
}
}
const {
searchQuery,
sortField,
sortOrder,
size,
offset
searchQuery,
sortField,
sortOrder,
size,
offset
} = this.toPaginationOptions(req.query, ["u.name", "u.email"], true);
const paginate = req.query.all === "false" ? `LIMIT ${size} OFFSET ${offset}` : "";
@@ -126,7 +232,7 @@ export default class TeamMembersController extends WorklenzControllerBase {
ELSE FALSE END) AS is_owner,
(SELECT email
FROM team_member_info_view
WHERE team_member_info_view.team_member_id = team_members.id),
WHERE team_member_info_view.team_member_id = team_members.id) AS email,
EXISTS(SELECT email
FROM email_invitations
WHERE team_member_id = team_members.id
@@ -277,12 +383,33 @@ export default class TeamMembersController extends WorklenzControllerBase {
if (!id || !req.user?.team_id) return res.status(200).send(new ServerResponse(false, "Required fields are missing."));
// check subscription status
const subscriptionData = await checkTeamSubscriptionStatus(req.user?.team_id);
if (statusExclude.includes(subscriptionData.subscription_status)) {
return res.status(200).send(new ServerResponse(false, "Please check your subscription status."));
}
const q = `SELECT remove_team_member($1, $2, $3) AS member;`;
const result = await db.query(q, [id, req.user?.id, req.user?.team_id]);
const [data] = result.rows;
const message = `You have been removed from <b>${req.user?.team_name}</b> by <b>${req.user?.name}</b>`;
// if (subscriptionData.status === "trialing") break;
// if (!subscriptionData.is_credit && !subscriptionData.is_custom) {
// if (subscriptionData.subscription_status === "active" && subscriptionData.quantity > 0) {
// const obj = await getActiveTeamMemberCount(req.user?.owner_id ?? "");
// // const activeObj = await getActiveTeamMemberCount(req.user?.owner_id ?? "");
// const userActiveInOtherTeams = await this.checkIfUserActiveInOtherTeams(req.user?.owner_id as string, req.query?.email as string);
// if (!userActiveInOtherTeams) {
// const response = await updateUsers(subscriptionData.subscription_id, obj.user_count);
// if (!response.body.subscription_id) return res.status(200).send(new ServerResponse(false, response.message || "Please check your subscription."));
// }
// }
// }
NotificationsService.sendNotification({
receiver_socket_id: data.socket_id,
message,
@@ -871,20 +998,68 @@ export default class TeamMembersController extends WorklenzControllerBase {
public static async toggleMemberActiveStatus(req: IWorkLenzRequest, res: IWorkLenzResponse) {
if (!req.user?.team_id) return res.status(200).send(new ServerResponse(false, "Required fields are missing."));
const q1 = `SELECT active FROM team_members WHERE id = $1;`;
const result1 = await db.query(q1, [req.params?.id]);
const [status] = result1.rows;
if (status.active) {
const updateQ1 = `UPDATE users
SET active_team = (SELECT id FROM teams WHERE user_id = users.id ORDER BY created_at DESC LIMIT 1)
WHERE id = (SELECT user_id FROM team_members WHERE id = $1 AND active IS TRUE LIMIT 1);`;
await db.query(updateQ1, [req.params?.id]);
// check subscription status
const subscriptionData = await checkTeamSubscriptionStatus(req.user?.team_id);
if (statusExclude.includes(subscriptionData.subscription_status)) {
return res.status(200).send(new ServerResponse(false, "Please check your subscription status."));
}
const q = `UPDATE team_members SET active = NOT active WHERE id = $1 RETURNING active;`;
const result = await db.query(q, [req.params?.id]);
const [data] = result.rows;
let data: any;
if (req.query.active === "true") {
const q1 = `SELECT active FROM team_members WHERE id = $1;`;
const result1 = await db.query(q1, [req.params?.id]);
const [status] = result1.rows;
if (status.active) {
const updateQ1 = `UPDATE users
SET active_team = (SELECT id FROM teams WHERE user_id = users.id ORDER BY created_at DESC LIMIT 1)
WHERE id = (SELECT user_id FROM team_members WHERE id = $1 AND active IS TRUE LIMIT 1);`;
await db.query(updateQ1, [req.params?.id]);
}
const q = `UPDATE team_members SET active = NOT active WHERE id = $1 RETURNING active;`;
const result = await db.query(q, [req.params?.id]);
data = result.rows[0];
// const userExists = await this.checkIfUserActiveInOtherTeams(req.user?.owner_id as string, req.query?.email as string);
// if (subscriptionData.status === "trialing") break;
// if (!userExists && !subscriptionData.is_credit && !subscriptionData.is_custom) {
// if (subscriptionData.subscription_status === "active" && subscriptionData.quantity > 0) {
// const operator = req.query.active === "true" ? - 1 : + 1;
// const response = await updateUsers(subscriptionData.subscription_id, subscriptionData.quantity + operator);
// if (!response.body.subscription_id) return res.status(200).send(new ServerResponse(false, response.message || "Please check your subscription."));
// }
// }
} else {
const userExists = await this.checkIfUserActiveInOtherTeams(req.user?.owner_id as string, req.query?.email as string);
// if (subscriptionData.status === "trialing") break;
// if (!userExists && !subscriptionData.is_credit && !subscriptionData.is_custom) {
// if (subscriptionData.subscription_status === "active" && subscriptionData.quantity > 0) {
// const operator = req.query.active === "true" ? - 1 : + 1;
// const response = await updateUsers(subscriptionData.subscription_id, subscriptionData.quantity + operator);
// if (!response.body.subscription_id) return res.status(200).send(new ServerResponse(false, response.message || "Please check your subscription."));
// }
// }
const q1 = `SELECT active FROM team_members WHERE id = $1;`;
const result1 = await db.query(q1, [req.params?.id]);
const [status] = result1.rows;
if (status.active) {
const updateQ1 = `UPDATE users
SET active_team = (SELECT id FROM teams WHERE user_id = users.id ORDER BY created_at DESC LIMIT 1)
WHERE id = (SELECT user_id FROM team_members WHERE id = $1 AND active IS TRUE LIMIT 1);`;
await db.query(updateQ1, [req.params?.id]);
}
const q = `UPDATE team_members SET active = NOT active WHERE id = $1 RETURNING active;`;
const result = await db.query(q, [req.params?.id]);
data = result.rows[0];
}
return res.status(200).send(new ServerResponse(true, [], `Team member ${data.active ? " activated" : " deactivated"} successfully.`));
}
@@ -899,6 +1074,21 @@ export default class TeamMembersController extends WorklenzControllerBase {
if (!req.body.team_id || !req.user?.id) return res.status(200).send(new ServerResponse(false, "Required fields are missing."));
// check the subscription status
const subscriptionData = await checkTeamSubscriptionStatus(req.body.team_id);
if (statusExclude.includes(subscriptionData.subscription_status)) {
return res.status(200).send(new ServerResponse(false, "Please check your subscription status."));
}
// if (subscriptionData.status === "trialing") break;
if (!subscriptionData.is_credit && !subscriptionData.is_custom) {
if (subscriptionData.subscription_status === "active") {
const response = await updateUsers(subscriptionData.subscription_id, subscriptionData.quantity + (req.body.emails.length || 1));
if (!response.body.subscription_id) return res.status(200).send(new ServerResponse(false, response.message || "Please check your subscription."));
}
}
const newMembers = await this.createOrInviteMembers(req.body, req.user);
return res.status(200).send(new ServerResponse(true, newMembers, `Your teammates will get an email that gives them access to your team.`).withTitle("Invitations sent"));
}

View File

@@ -16,8 +16,8 @@ export default class TimezonesController extends WorklenzControllerBase {
@HandleExceptions()
public static async update(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
const q = `UPDATE users SET timezone_id = $2 WHERE id = $1;`;
const result = await db.query(q, [req.user?.id, req.body.timezone]);
return res.status(200).send(new ServerResponse(true, result.rows, "Timezone updated"));
const q = `UPDATE users SET timezone_id = $2, language = $3 WHERE id = $1;`;
const result = await db.query(q, [req.user?.id, req.body.timezone, req.body.language]);
return res.status(200).send(new ServerResponse(true, result.rows, "Updated successfully"));
}
}