Initial commit: Angular frontend and Expressjs backend
This commit is contained in:
133
worklenz-backend/src/shared/constants.ts
Normal file
133
worklenz-backend/src/shared/constants.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
export const DUPLICATE_KEY_VALUE = "23505";
|
||||
export const FOREIGN_KEY_VIOLATION = "23503";
|
||||
|
||||
export const DEFAULT_ERROR_MESSAGE = "Unknown error has occurred.";
|
||||
|
||||
export const SessionsStatus = {
|
||||
IDLE: "IDLE",
|
||||
STARTED: "STARTED",
|
||||
ENDED: "ENDED"
|
||||
};
|
||||
|
||||
export const LOG_DESCRIPTIONS = {
|
||||
PROJECT_CREATED: "Project created by @user",
|
||||
PROJECT_UPDATED: "Project updated by @user",
|
||||
TASK_CREATED: "Task created by @user",
|
||||
TASK_UPDATED: "Task updated by @user",
|
||||
PROJECT_MEMBER_ADDED: "was added to the project by",
|
||||
PROJECT_MEMBER_REMOVED: "was removed from the project by",
|
||||
};
|
||||
|
||||
export const WorklenzColorCodes = [
|
||||
"#154c9b",
|
||||
"#3b7ad4",
|
||||
"#70a6f3",
|
||||
"#7781ca",
|
||||
"#9877ca",
|
||||
"#c178c9",
|
||||
"#ee87c5",
|
||||
"#ca7881",
|
||||
"#75c9c0",
|
||||
"#75c997",
|
||||
"#80ca79",
|
||||
"#aacb78",
|
||||
"#cbbc78",
|
||||
"#cb9878",
|
||||
"#bb774c",
|
||||
"#905b39",
|
||||
"#903737",
|
||||
"#bf4949",
|
||||
"#f37070",
|
||||
"#ff9c3c",
|
||||
"#fbc84c",
|
||||
"#cbc8a1",
|
||||
"#a9a9a9",
|
||||
"#767676"
|
||||
];
|
||||
|
||||
export const AvatarNamesMap: { [x: string]: string } = {
|
||||
"A": "#154c9b",
|
||||
"B": "#3b7ad4",
|
||||
"C": "#70a6f3",
|
||||
"D": "#7781ca",
|
||||
"E": "#9877ca",
|
||||
"F": "#c178c9",
|
||||
"G": "#ee87c5",
|
||||
"H": "#ca7881",
|
||||
"I": "#75c9c0",
|
||||
"J": "#75c997",
|
||||
"K": "#80ca79",
|
||||
"L": "#aacb78",
|
||||
"M": "#cbbc78",
|
||||
"N": "#cb9878",
|
||||
"O": "#bb774c",
|
||||
"P": "#905b39",
|
||||
"Q": "#903737",
|
||||
"R": "#bf4949",
|
||||
"S": "#f37070",
|
||||
"T": "#ff9c3c",
|
||||
"U": "#fbc84c",
|
||||
"V": "#cbc8a1",
|
||||
"W": "#a9a9a9",
|
||||
"X": "#767676",
|
||||
"Y": "#cb9878",
|
||||
"Z": "#903737",
|
||||
"+": "#9e9e9e"
|
||||
};
|
||||
|
||||
export const NumbersColorMap: { [x: string]: string } = {
|
||||
"0": "#154c9b",
|
||||
"1": "#3b7ad4",
|
||||
"2": "#70a6f3",
|
||||
"3": "#7781ca",
|
||||
"4": "#9877ca",
|
||||
"5": "#c178c9",
|
||||
"6": "#ee87c5",
|
||||
"7": "#ca7881",
|
||||
"8": "#75c9c0",
|
||||
"9": "#75c997"
|
||||
};
|
||||
|
||||
export const PriorityColorCodes: { [x: number]: string; } = {
|
||||
0: "#75c997",
|
||||
1: "#fbc84c",
|
||||
2: "#f37070"
|
||||
};
|
||||
|
||||
export const TASK_STATUS_TODO_COLOR = "#a9a9a9";
|
||||
export const TASK_STATUS_DOING_COLOR = "#70a6f3";
|
||||
export const TASK_STATUS_DONE_COLOR = "#75c997";
|
||||
|
||||
export const TASK_PRIORITY_LOW_COLOR = "#75c997";
|
||||
export const TASK_PRIORITY_MEDIUM_COLOR = "#fbc84c";
|
||||
export const TASK_PRIORITY_HIGH_COLOR = "#f37070";
|
||||
|
||||
export const TASK_DUE_COMPLETED_COLOR = "#75c997";
|
||||
export const TASK_DUE_UPCOMING_COLOR = "#70a6f3";
|
||||
export const TASK_DUE_OVERDUE_COLOR = "#f37070";
|
||||
export const TASK_DUE_NO_DUE_COLOR = "#a9a9a9";
|
||||
|
||||
|
||||
export const DEFAULT_PAGE_SIZE = 20;
|
||||
|
||||
export const TASK_STATUS_COLOR_ALPHA = "69";
|
||||
export const TASK_PRIORITY_COLOR_ALPHA = "69";
|
||||
export const TEAM_MEMBER_TREE_MAP_COLOR_ALPHA = "40";
|
||||
|
||||
// *Sync with the client
|
||||
export const PASSWORD_POLICY = "Minimum of 8 characters, with upper and lowercase and a number and a symbol.";
|
||||
|
||||
// paddle status to exclude
|
||||
export const statusExclude = ["past_due", "paused", "deleted"];
|
||||
|
||||
export const HTML_TAG_REGEXP = /<\/?[^>]+>/gi;
|
||||
|
||||
export const UNMAPPED = "Unmapped";
|
||||
|
||||
export const DATE_RANGES = {
|
||||
YESTERDAY: "YESTERDAY",
|
||||
LAST_WEEK: "LAST_WEEK",
|
||||
LAST_MONTH: "LAST_MONTH",
|
||||
LAST_QUARTER: "LAST_QUARTER",
|
||||
ALL_TIME: "ALL_TIME"
|
||||
};
|
||||
69
worklenz-backend/src/shared/constraints.ts
Normal file
69
worklenz-backend/src/shared/constraints.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* Map all unique indexes, constrains from a database here
|
||||
* to send a meaningful message
|
||||
*
|
||||
* NOTE:
|
||||
* Adding "$" sign to the start of the message will ignore displaying it
|
||||
* to the end-user by the client's interceptor
|
||||
*
|
||||
* Adding "[IGNORE]" as the message sends a success empty response to the client
|
||||
* */
|
||||
export const DB_CONSTRAINS: { [x: string]: string | null } = {
|
||||
|
||||
// Unique indexes
|
||||
project_access_levels_key_uindex: "",
|
||||
project_access_levels_name_uindex: "",
|
||||
task_priorities_name_uindex: "",
|
||||
clients_name_team_id_uindex: "Client name already exists. Please choose a different name.",
|
||||
job_titles_name_team_id_uindex: "Job title already exists. Please choose a different name.",
|
||||
users_email_uindex: "A Worklenz account already exists for this email address. Please choose a different email.",
|
||||
users_google_id_uindex: "",
|
||||
users_socket_id_uindex: "",
|
||||
team_members_user_id_team_id_uindex: "Team member with this email already exists.",
|
||||
project_members_team_member_project_uindex: "[IGNORE]",
|
||||
task_statuses_project_id_name_uindex: "Status already exists. Please choose a different name.",
|
||||
tasks_name_project_uindex: "Task name already exists. Please choose a different name.",
|
||||
tasks_assignee_task_project_uindex: "",
|
||||
permissions_name_uindex: "",
|
||||
roles_name_team_id_uindex: "",
|
||||
roles_default_uindex: "",
|
||||
roles_owner_uindex: "",
|
||||
personal_todo_list_index_uindex: "",
|
||||
team_labels_name_team_uindex: "Labels cannot be duplicated.",
|
||||
projects_key_team_id_uindex: "Try to use a different team name.",
|
||||
project_folders_team_id_name_uindex: "Folder already exists. Use a different folder name.",
|
||||
project_phases_name_project_uindex: "Option name already exists. Use a different name.",
|
||||
project_categories_name_team_id_uindex: "Category already exists. Use a different name",
|
||||
// Status already exists. Please choose a different name.
|
||||
|
||||
// Keys
|
||||
tasks_project_fk: "This project has tasks associated with it. Please delete all tasks before deleting the project.",
|
||||
tasks_assignees_pk: null,
|
||||
tasks_status_id_fk: "$One or more tasks (archived/non-archived) will be affected. Please select another status to move the tasks",
|
||||
|
||||
// Check constrains
|
||||
projects_color_code_check: "",
|
||||
sys_task_status_categories_color_code_check: "",
|
||||
tasks_total_minutes_check: "",
|
||||
tasks_task_order_check: "",
|
||||
personal_todo_list_color_code_check: "",
|
||||
task_work_log_time_spent_check: "Invalid log details",
|
||||
task_comment_contents_content_check: "",
|
||||
|
||||
teams_name_check: "Team name size exceeded",
|
||||
clients_name_check: "Client name size exceeded",
|
||||
job_titles_name_check: "Job title name size exceeded",
|
||||
users_name_check: "User name size exceeded",
|
||||
users_email_check: "Email size exceeded",
|
||||
projects_name_check: "Project name size exceeded",
|
||||
projects_notes_check: "Project note size exceeded",
|
||||
task_statuses_name_check: "Task status name size exceeded",
|
||||
tasks_name_check: "Task name size exceeded",
|
||||
tasks_description_check: "Task description size exceeded",
|
||||
team_labels_name_check: "Label name size exceeded",
|
||||
personal_todo_list_name_check: "Name size exceeded",
|
||||
personal_todo_list_description_check: "Description size exceeded",
|
||||
task_work_log_description_check: "Description size exceeded",
|
||||
task_comment_contents_name_check: "Comment size exceeded",
|
||||
task_attachments_name_check: "File name size exceeded",
|
||||
};
|
||||
56
worklenz-backend/src/shared/csp.ts
Normal file
56
worklenz-backend/src/shared/csp.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
const policies = {
|
||||
"default-src": ["'self'"],
|
||||
"script-src": [
|
||||
"'self'",
|
||||
"data:",
|
||||
"'unsafe-inline'",
|
||||
"https://js.usemessages.com",
|
||||
"https://*.tiny.cloud",
|
||||
],
|
||||
"media-src": [
|
||||
"https://s3.us-west-2.amazonaws.com"
|
||||
],
|
||||
"style-src": [
|
||||
"'self'",
|
||||
"'unsafe-inline'",
|
||||
"data:",
|
||||
"https://cdnjs.cloudflare.com",
|
||||
"https://fonts.googleapis.com",
|
||||
"https://*.tiny.cloud",
|
||||
],
|
||||
"font-src": [
|
||||
"'self'",
|
||||
"data:",
|
||||
"https://fonts.gstatic.com",
|
||||
"https://cdnjs.cloudflare.com",
|
||||
],
|
||||
"worker-src": ["'self'"],
|
||||
"connect-src": [
|
||||
"'self'",
|
||||
"data",
|
||||
"https://js.usemessages.com",
|
||||
"https://cdnjs.cloudflare.com",
|
||||
"https://fonts.googleapis.com",
|
||||
"https://fonts.gstatic.com",
|
||||
"https://s3.us-west-2.amazonaws.com",
|
||||
"https://s3.scriptcdn.net",
|
||||
"https://*.tiny.cloud",
|
||||
"https://*.tinymce.com",
|
||||
],
|
||||
"img-src": [
|
||||
"'self'",
|
||||
"data:",
|
||||
"https://s3.us-west-2.amazonaws.com",
|
||||
"https://*.tinymce.com",
|
||||
],
|
||||
"frame-src": ["https://docs.google.com"],
|
||||
"frame-ancestors": ["'none'"],
|
||||
"object-src": ["'none'"],
|
||||
"report-to": [`https://${process.env.HOSTNAME}/-/csp`]
|
||||
};
|
||||
|
||||
const policyString = Object.entries(policies)
|
||||
.map(([key, value]) => `${key} ${value.join(" ")}`)
|
||||
.join("; ");
|
||||
|
||||
export const CSP_POLICIES = policyString;
|
||||
122
worklenz-backend/src/shared/email-notifications.ts
Normal file
122
worklenz-backend/src/shared/email-notifications.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import {compileTemplate} from "pug";
|
||||
import db from "../config/db";
|
||||
import {IDailyDigest} from "../interfaces/daily-digest";
|
||||
import {IEmailTemplateType} from "../interfaces/email-template-type";
|
||||
import {ITaskAssignmentsModel} from "../interfaces/task-assignments-model";
|
||||
import {sendEmail} from "./email";
|
||||
import FileConstants from "./file-constants";
|
||||
import {log_error} from "./utils";
|
||||
import {ITaskMovedToDoneRecord} from "../interfaces/task-moved-to-done";
|
||||
import {IProjectDigest} from "../interfaces/project-digest";
|
||||
import {ICommentEmailNotification, IProjectCommentEmailNotification} from "../interfaces/comment-email-notification";
|
||||
|
||||
async function updateTaskUpdatesStatus(isSent: boolean) {
|
||||
try {
|
||||
const q = isSent
|
||||
? "DELETE FROM task_updates WHERE is_sent IS TRUE;"
|
||||
: "UPDATE task_updates SET is_sent = FALSE;";
|
||||
|
||||
await db.query(q, []);
|
||||
} catch (error) {
|
||||
log_error(error);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async function addToEmailLogs(email: string, subject: string, html: string) {
|
||||
try {
|
||||
const q = `INSERT INTO email_logs (email, subject, html) VALUES ($1, $2, $3);`;
|
||||
await db.query(q, [email, subject, html]);
|
||||
} catch (error) {
|
||||
log_error(error);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export async function sendAssignmentUpdate(toEmail: string, assignment: ITaskAssignmentsModel) {
|
||||
try {
|
||||
const template = FileConstants.getEmailTemplate(IEmailTemplateType.TaskAssigneeChange) as compileTemplate;
|
||||
const isSent = assignment.teams?.length
|
||||
? await sendEmail({
|
||||
subject: "You have new assignments on Worklenz",
|
||||
to: [toEmail],
|
||||
html: template(assignment)
|
||||
})
|
||||
: true;
|
||||
|
||||
await updateTaskUpdatesStatus(!!isSent);
|
||||
addToEmailLogs(toEmail, "You have new assignments on Worklenz", template(assignment));
|
||||
} catch (e) {
|
||||
log_error(e);
|
||||
await updateTaskUpdatesStatus(false);
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendDailyDigest(toEmail: string, digest: IDailyDigest) {
|
||||
try {
|
||||
const template = FileConstants.getEmailTemplate(IEmailTemplateType.DailyDigest) as compileTemplate;
|
||||
await sendEmail({
|
||||
subject: digest.note as string,
|
||||
to: [toEmail],
|
||||
html: template(digest)
|
||||
});
|
||||
} catch (e) {
|
||||
log_error(e);
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendTaskDone(toEmails: string[], data: ITaskMovedToDoneRecord) {
|
||||
try {
|
||||
const template = FileConstants.getEmailTemplate(IEmailTemplateType.TaskDone) as compileTemplate;
|
||||
await sendEmail({
|
||||
subject: data.summary,
|
||||
to: toEmails,
|
||||
html: template(data)
|
||||
});
|
||||
} catch (e) {
|
||||
log_error(e);
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendProjectDailyDigest(toEmail: string, digest: IProjectDigest) {
|
||||
try {
|
||||
const template = FileConstants.getEmailTemplate(IEmailTemplateType.ProjectDailyDigest) as compileTemplate;
|
||||
await sendEmail({
|
||||
subject: digest.summary,
|
||||
to: [toEmail],
|
||||
html: template(digest)
|
||||
});
|
||||
} catch (e) {
|
||||
log_error(e);
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendTaskComment(toEmail: string, data: ICommentEmailNotification) {
|
||||
try {
|
||||
const template = FileConstants.getEmailTemplate(IEmailTemplateType.TaskComment) as compileTemplate;
|
||||
return await sendEmail({
|
||||
subject: data.summary,
|
||||
to: [toEmail],
|
||||
html: template(data)
|
||||
});
|
||||
} catch (e) {
|
||||
log_error(e);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function sendProjectComment(toEmail: string, data: IProjectCommentEmailNotification) {
|
||||
try {
|
||||
const template = FileConstants.getEmailTemplate(IEmailTemplateType.ProjectComment) as compileTemplate;
|
||||
return await sendEmail({
|
||||
subject: data.summary,
|
||||
to: [toEmail],
|
||||
html: template(data)
|
||||
});
|
||||
} catch (e) {
|
||||
log_error(e);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
108
worklenz-backend/src/shared/email-templates.ts
Normal file
108
worklenz-backend/src/shared/email-templates.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import {IEmailTemplateType} from "../interfaces/email-template-type";
|
||||
import {IPassportSession} from "../interfaces/passport-session";
|
||||
import {sendEmail} from "./email";
|
||||
import {sanitize} from "./utils";
|
||||
import FileConstants from "./file-constants";
|
||||
|
||||
const HOSTNAME = process.env.HOSTNAME || "worklenz.com";
|
||||
|
||||
export function sendWelcomeEmail(email: string, name: string) {
|
||||
let content = FileConstants.getEmailTemplate(IEmailTemplateType.Welcome) as string;
|
||||
if (!content) return;
|
||||
|
||||
content = content.replace("[VAR_USER_NAME]", sanitize(name));
|
||||
content = content.replace("[VAR_HOSTNAME]", sanitize(HOSTNAME));
|
||||
|
||||
sendEmail({
|
||||
to: [email],
|
||||
subject: "Welcome to Worklenz.",
|
||||
html: content
|
||||
});
|
||||
}
|
||||
|
||||
export function sendNewSubscriberNotification(subscriberEmail: string) {
|
||||
let content = FileConstants.getEmailTemplate(IEmailTemplateType.NewSubscriber) as string;
|
||||
if (!content) return;
|
||||
|
||||
content = content.replace("[VAR_EMAIL]", sanitize(subscriberEmail));
|
||||
|
||||
sendEmail({
|
||||
subject: "Worklenz - New Subscriber.",
|
||||
html: content
|
||||
});
|
||||
}
|
||||
|
||||
export function sendJoinTeamInvitation(myName: string, teamName: string, teamId: string, userName: string, toEmail: string, userId: string, projectId?: string) {
|
||||
let content = FileConstants.getEmailTemplate(IEmailTemplateType.TeamMemberInvitation) as string;
|
||||
if (!content) return;
|
||||
|
||||
content = content.replace("[VAR_USER_NAME]", sanitize(userName));
|
||||
content = content.replace("[VAR_TEAM_NAME]", sanitize(teamName));
|
||||
content = content.replace("[VAR_HOSTNAME]", sanitize(HOSTNAME));
|
||||
content = content.replace("[VAR_TEAM_ID]", sanitize(teamId));
|
||||
content = content.replace("[VAR_USER_ID]", sanitize(userId));
|
||||
content = content.replace("[PROJECT_ID]", projectId ? sanitize(projectId as string) : "");
|
||||
|
||||
sendEmail({
|
||||
to: [toEmail],
|
||||
subject: `${myName} has invited you to work with ${teamName} in Worklenz`,
|
||||
html: content
|
||||
});
|
||||
}
|
||||
|
||||
export function sendRegisterAndJoinTeamInvitation(myName: string, userName: string, teamName: string, teamId: string, userId: string, toEmail: string, projectId?: string) {
|
||||
let content = FileConstants.getEmailTemplate(IEmailTemplateType.UnregisteredTeamMemberInvitation) as string;
|
||||
if (!content) return;
|
||||
|
||||
content = content.replace("[VAR_EMAIL]", sanitize(toEmail));
|
||||
content = content.replace("[VAR_USER_ID]", sanitize(userId));
|
||||
content = content.replace("[VAR_USER_NAME]", sanitize(userName));
|
||||
content = content.replace("[VAR_TEAM_NAME]", sanitize(teamName));
|
||||
content = content.replace("[VAR_HOSTNAME]", sanitize(HOSTNAME));
|
||||
content = content.replace("[VAR_TEAM_ID]", sanitize(teamId));
|
||||
content = content.replace("[PROJECT_ID]", projectId ? sanitize(projectId as string) : "");
|
||||
|
||||
sendEmail({
|
||||
to: [toEmail],
|
||||
subject: `${myName} has invited you to work with ${teamName} in Worklenz`,
|
||||
html: content
|
||||
});
|
||||
}
|
||||
|
||||
export function sendResetEmail(toEmail: string, user_id: string, hash: string) {
|
||||
let content = FileConstants.getEmailTemplate(IEmailTemplateType.ResetPassword) as string;
|
||||
if (!content) return;
|
||||
|
||||
content = content.replace("[VAR_HOSTNAME]", sanitize(HOSTNAME));
|
||||
content = content.replace("[VAR_USER_ID]", sanitize(user_id));
|
||||
content = content.replace("[VAR_HASH]", hash);
|
||||
|
||||
sendEmail({
|
||||
to: [toEmail],
|
||||
subject: "Reset your password on Worklenz.",
|
||||
html: content
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
export function sendResetSuccessEmail(toEmail: string) {
|
||||
const content = FileConstants.getEmailTemplate(IEmailTemplateType.PasswordChange) as string;
|
||||
if (!content) return;
|
||||
|
||||
sendEmail({
|
||||
to: [toEmail],
|
||||
subject: "Your password was reset.",
|
||||
html: content
|
||||
});
|
||||
}
|
||||
|
||||
// * This implementation should be improved
|
||||
export function sendInvitationEmail(isNewMember: boolean, user: IPassportSession, userNameOrId: string, email: string, userId: string, userName?: string, projectId?: string) {
|
||||
if (isNewMember) {
|
||||
// userNameOrId = userName
|
||||
sendJoinTeamInvitation(user?.name as string, user?.team_name as string, user.team_id as string, userNameOrId, email, userId, projectId);
|
||||
} else {
|
||||
// userNameOrId = userId
|
||||
sendRegisterAndJoinTeamInvitation(user?.name as string, userName as string, user?.team_name as string, user.team_id as string, userNameOrId, email, projectId);
|
||||
}
|
||||
}
|
||||
92
worklenz-backend/src/shared/email.ts
Normal file
92
worklenz-backend/src/shared/email.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import {SendEmailCommand, SESClient} from "@aws-sdk/client-ses";
|
||||
import {Validator} from "jsonschema";
|
||||
import {QueryResult} from "pg";
|
||||
import {log_error} from "./utils";
|
||||
import emailRequestSchema from "../json_schemas/email-request-schema";
|
||||
import db from "../config/db";
|
||||
|
||||
const sesClient = new SESClient({region: process.env.AWS_REGION});
|
||||
|
||||
export interface IEmail {
|
||||
to?: string[];
|
||||
subject: string;
|
||||
html: string;
|
||||
}
|
||||
|
||||
export class EmailRequest implements IEmail {
|
||||
public readonly html: string;
|
||||
public readonly subject: string;
|
||||
public readonly to: string[];
|
||||
|
||||
constructor(toEmails: string[], subject: string, content: string) {
|
||||
this.to = toEmails;
|
||||
this.subject = subject;
|
||||
this.html = content;
|
||||
}
|
||||
}
|
||||
|
||||
function isValidMailBody(body: IEmail) {
|
||||
const validator = new Validator();
|
||||
return validator.validate(body, emailRequestSchema).valid;
|
||||
}
|
||||
|
||||
async function removeMails(query: string, emails: string[]) {
|
||||
const result: QueryResult<{ email: string; }> = await db.query(query, []);
|
||||
const bouncedEmails = result.rows.map(e => e.email);
|
||||
for (let i = 0; i < emails.length; i++) {
|
||||
const email = emails[i];
|
||||
if (bouncedEmails.includes(email)) {
|
||||
emails.splice(i, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function filterSpamEmails(emails: string[]): Promise<void> {
|
||||
await removeMails("SELECT email FROM spam_emails ORDER BY email;", emails);
|
||||
}
|
||||
|
||||
async function filterBouncedEmails(emails: string[]): Promise<void> {
|
||||
await removeMails("SELECT email FROM bounced_emails ORDER BY email;", emails);
|
||||
}
|
||||
|
||||
export async function sendEmail(email: IEmail): Promise<string | null> {
|
||||
try {
|
||||
const options = {...email} as IEmail;
|
||||
options.to = Array.isArray(options.to) ? Array.from(new Set(options.to)) : [];
|
||||
|
||||
if (options.to.length) {
|
||||
await filterBouncedEmails(options.to);
|
||||
await filterSpamEmails(options.to);
|
||||
}
|
||||
|
||||
if (!isValidMailBody(options)) return null;
|
||||
|
||||
const charset = "UTF-8";
|
||||
|
||||
const command = new SendEmailCommand({
|
||||
Destination: {
|
||||
ToAddresses: options.to
|
||||
},
|
||||
Message: {
|
||||
Subject: {
|
||||
Charset: charset,
|
||||
Data: options.subject
|
||||
},
|
||||
Body: {
|
||||
Html: {
|
||||
Charset: charset,
|
||||
Data: options.html
|
||||
}
|
||||
}
|
||||
},
|
||||
Source: "SOURCE_EMAIL_HERE" // Ex: Worklenz <noreply@worklenz.com>
|
||||
});
|
||||
|
||||
const res = await sesClient.send(command);
|
||||
return res.MessageId || null;
|
||||
} catch (e) {
|
||||
log_error(e);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
95
worklenz-backend/src/shared/file-constants.ts
Normal file
95
worklenz-backend/src/shared/file-constants.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
/* eslint-disable security/detect-object-injection */
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import pug from "pug";
|
||||
import {IEmailTemplateType} from "../interfaces/email-template-type";
|
||||
|
||||
class FileConstants {
|
||||
private static release: string | null = null;
|
||||
private static readonly EMAIL_TEMPLATES_MAP: { [x: string]: string } = {};
|
||||
private static readonly PUG_EMAIL_TEMPLATES_MAP: { [x: string]: pug.compileTemplate } = {};
|
||||
private static readonly EMAIL_TEMPLATES_BASE = "../../worklenz-email-templates";
|
||||
|
||||
static init() {
|
||||
FileConstants.getRelease();
|
||||
FileConstants.initEmailTemplates();
|
||||
}
|
||||
|
||||
private static readHtmlEmailTemplate(fileName: string) {
|
||||
const key = fileName.toString();
|
||||
|
||||
if (!FileConstants.EMAIL_TEMPLATES_MAP[key]) {
|
||||
const url = path.join(__dirname, FileConstants.EMAIL_TEMPLATES_BASE, `${fileName}.html`);
|
||||
FileConstants.EMAIL_TEMPLATES_MAP[key] = fs.readFileSync(url, "utf8");
|
||||
}
|
||||
|
||||
return FileConstants.EMAIL_TEMPLATES_MAP[key];
|
||||
}
|
||||
|
||||
private static readPugEmailTemplate(fileName: string) {
|
||||
const key = fileName.toString();
|
||||
|
||||
if (!FileConstants.PUG_EMAIL_TEMPLATES_MAP[key]) {
|
||||
const filePath = path.join(__dirname, FileConstants.EMAIL_TEMPLATES_BASE, "email-notifications", `${fileName}.pug`);
|
||||
const template = pug.compileFile(filePath);
|
||||
FileConstants.PUG_EMAIL_TEMPLATES_MAP[key] = template;
|
||||
}
|
||||
|
||||
return FileConstants.PUG_EMAIL_TEMPLATES_MAP[key];
|
||||
}
|
||||
|
||||
private static initEmailTemplates() {
|
||||
FileConstants.getEmailTemplate(IEmailTemplateType.NewSubscriber);
|
||||
FileConstants.getEmailTemplate(IEmailTemplateType.TeamMemberInvitation);
|
||||
FileConstants.getEmailTemplate(IEmailTemplateType.UnregisteredTeamMemberInvitation);
|
||||
FileConstants.getEmailTemplate(IEmailTemplateType.PasswordChange);
|
||||
FileConstants.getEmailTemplate(IEmailTemplateType.Welcome);
|
||||
FileConstants.getEmailTemplate(IEmailTemplateType.OTPVerification);
|
||||
FileConstants.getEmailTemplate(IEmailTemplateType.ResetPassword);
|
||||
FileConstants.getEmailTemplate(IEmailTemplateType.TaskAssigneeChange);
|
||||
FileConstants.getEmailTemplate(IEmailTemplateType.DailyDigest);
|
||||
FileConstants.getEmailTemplate(IEmailTemplateType.TaskDone);
|
||||
FileConstants.getEmailTemplate(IEmailTemplateType.ProjectDailyDigest);
|
||||
FileConstants.getEmailTemplate(IEmailTemplateType.TaskComment);
|
||||
}
|
||||
|
||||
static getEmailTemplate(type: IEmailTemplateType) {
|
||||
switch (type) {
|
||||
case IEmailTemplateType.NewSubscriber:
|
||||
return FileConstants.readHtmlEmailTemplate("admin-new-subscriber-notification");
|
||||
case IEmailTemplateType.TeamMemberInvitation:
|
||||
return FileConstants.readHtmlEmailTemplate("team-invitation");
|
||||
case IEmailTemplateType.UnregisteredTeamMemberInvitation:
|
||||
return FileConstants.readHtmlEmailTemplate("unregistered-team-invitation-notification");
|
||||
case IEmailTemplateType.PasswordChange:
|
||||
return FileConstants.readHtmlEmailTemplate("password-changed-notification");
|
||||
case IEmailTemplateType.Welcome:
|
||||
return FileConstants.readHtmlEmailTemplate("welcome");
|
||||
case IEmailTemplateType.OTPVerification:
|
||||
return FileConstants.readHtmlEmailTemplate("otp-verfication-code");
|
||||
case IEmailTemplateType.ResetPassword:
|
||||
return FileConstants.readHtmlEmailTemplate("reset-password");
|
||||
case IEmailTemplateType.TaskAssigneeChange:
|
||||
return FileConstants.readPugEmailTemplate("task-assignee-change");
|
||||
case IEmailTemplateType.DailyDigest:
|
||||
return FileConstants.readPugEmailTemplate("daily-digest");
|
||||
case IEmailTemplateType.TaskDone:
|
||||
return FileConstants.readPugEmailTemplate("task-moved-to-done");
|
||||
case IEmailTemplateType.ProjectDailyDigest:
|
||||
return FileConstants.readPugEmailTemplate("project-daily-digest");
|
||||
case IEmailTemplateType.TaskComment:
|
||||
return FileConstants.readPugEmailTemplate("task-comment");
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
static getRelease() {
|
||||
if (FileConstants.release === null) {
|
||||
FileConstants.release = fs.readFileSync(path.join(__dirname, "../../release"), "utf8").trim();
|
||||
}
|
||||
return FileConstants.release;
|
||||
}
|
||||
}
|
||||
|
||||
export default FileConstants;
|
||||
48
worklenz-backend/src/shared/io.ts
Normal file
48
worklenz-backend/src/shared/io.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import {Server} from "socket.io";
|
||||
import db from "../config/db";
|
||||
import {SocketEvents} from "../socket.io/events";
|
||||
|
||||
export class IO {
|
||||
private static instance: Server | null = null;
|
||||
|
||||
public static setInstance(io: Server) {
|
||||
this.instance = io;
|
||||
}
|
||||
|
||||
public static getInstance() {
|
||||
return this.instance;
|
||||
}
|
||||
|
||||
public static getSocketById(socketId: string) {
|
||||
return this.instance?.sockets.sockets?.get(socketId) || null;
|
||||
}
|
||||
|
||||
public static emit(event: SocketEvents, socketId: string, data?: any) {
|
||||
this.getSocketById(socketId)?.emit(event.toString(), data);
|
||||
}
|
||||
|
||||
public static async emitByUserId(id: string, userId: string | null, event: SocketEvents, data?: any) {
|
||||
try {
|
||||
if (id === userId) return;
|
||||
const q = `SELECT socket_id FROM users WHERE id = $1;`;
|
||||
const result = await db.query(q, [id]);
|
||||
const [user] = result.rows;
|
||||
if (!user) return;
|
||||
this.emit(event, user.socket_id, data);
|
||||
} catch (error) {
|
||||
// ignored
|
||||
}
|
||||
}
|
||||
|
||||
public static async emitByTeamMemberId(id: string, userId: string | null, event: SocketEvents, data?: any) {
|
||||
try {
|
||||
const q = `SELECT socket_id FROM users WHERE id != $1 AND id IN (SELECT user_id FROM team_members WHERE id = $2);`;
|
||||
const result = await db.query(q, [userId, id]);
|
||||
const [user] = result.rows;
|
||||
if (!user) return;
|
||||
this.emit(event, user.socket_id, data);
|
||||
} catch (error) {
|
||||
// ignored
|
||||
}
|
||||
}
|
||||
}
|
||||
78
worklenz-backend/src/shared/password-strength-check.ts
Normal file
78
worklenz-backend/src/shared/password-strength-check.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import {IPasswordValidityResult} from "../interfaces/password-validity-result";
|
||||
|
||||
export class PasswordStrengthChecker {
|
||||
private static readonly defaultOptions = [
|
||||
{
|
||||
value: 0,
|
||||
text: "Too weak",
|
||||
minDiversity: 0,
|
||||
minLength: 0
|
||||
},
|
||||
{
|
||||
value: 1,
|
||||
text: "Weak",
|
||||
minDiversity: 2,
|
||||
minLength: 6
|
||||
},
|
||||
{
|
||||
value: 2,
|
||||
text: "Strong",
|
||||
minDiversity: 4,
|
||||
minLength: 8
|
||||
},
|
||||
{
|
||||
value: 3,
|
||||
text: "Excellent",
|
||||
minDiversity: 4,
|
||||
minLength: 10
|
||||
}
|
||||
];
|
||||
private static readonly defaultAllowedSymbols = "!\"#\$%&'\(\)\*\+,-\./:;<=>\?@\[\\\\\\]\^_`\{|\}~";
|
||||
|
||||
public static validate(password: string, options = this.defaultOptions, allowedSymbols = this.defaultAllowedSymbols): IPasswordValidityResult {
|
||||
const passwordCopy = password || "";
|
||||
|
||||
options[0].minDiversity = 0;
|
||||
options[0].minLength = 0;
|
||||
|
||||
const rules = [
|
||||
{
|
||||
regex: "[a-z]",
|
||||
message: "lowercase"
|
||||
},
|
||||
{
|
||||
regex: "[A-Z]",
|
||||
message: "uppercase"
|
||||
},
|
||||
{
|
||||
regex: "[0-9]",
|
||||
message: "number"
|
||||
},
|
||||
];
|
||||
|
||||
if (allowedSymbols) {
|
||||
rules.push({
|
||||
regex: `[${allowedSymbols}]`,
|
||||
message: "symbol"
|
||||
});
|
||||
}
|
||||
|
||||
const strength: any = {};
|
||||
|
||||
strength.contains = rules
|
||||
.filter(rule => new RegExp(`${rule.regex}`).test(passwordCopy))
|
||||
.map(rule => rule.message);
|
||||
|
||||
strength.length = passwordCopy.length;
|
||||
|
||||
const fulfilledOptions = options
|
||||
.filter(option => strength.contains.length >= option.minDiversity)
|
||||
.filter(option => strength.length >= option.minLength)
|
||||
.sort((o1, o2) => o2.value - o1.value)
|
||||
.map(option => ({value: option.value, text: option.text}));
|
||||
|
||||
Object.assign(strength, fulfilledOptions[0]);
|
||||
|
||||
return strength;
|
||||
}
|
||||
}
|
||||
242
worklenz-backend/src/shared/postgresql-error-codes.json
Normal file
242
worklenz-backend/src/shared/postgresql-error-codes.json
Normal file
@@ -0,0 +1,242 @@
|
||||
{
|
||||
"00000": "successful_completion",
|
||||
"01000": "warning",
|
||||
"0100C": "dynamic_result_sets_returned",
|
||||
"01008": "implicit_zero_bit_padding",
|
||||
"01003": "null_value_eliminated_in_set_function",
|
||||
"01007": "privilege_not_granted",
|
||||
"01006": "privilege_not_revoked",
|
||||
"01004": "string_data_right_truncation",
|
||||
"01P01": "deprecated_feature",
|
||||
"02000": "no_data",
|
||||
"02001": "no_additional_dynamic_result_sets_returned",
|
||||
"03000": "sql_statement_not_yet_complete",
|
||||
"08000": "connection_exception",
|
||||
"08003": "connection_does_not_exist",
|
||||
"08006": "connection_failure",
|
||||
"08001": "sqlclient_unable_to_establish_sqlconnection",
|
||||
"08004": "sqlserver_rejected_establishment_of_sqlconnection",
|
||||
"08007": "transaction_resolution_unknown",
|
||||
"08P01": "protocol_violation",
|
||||
"09000": "triggered_action_exception",
|
||||
"0A000": "feature_not_supported",
|
||||
"0B000": "invalid_transaction_initiation",
|
||||
"0F000": "locator_exception",
|
||||
"0F001": "invalid_locator_specification",
|
||||
"0L000": "invalid_grantor",
|
||||
"0LP01": "invalid_grant_operation",
|
||||
"0P000": "invalid_role_specification",
|
||||
"0Z000": "diagnostics_exception",
|
||||
"0Z002": "stacked_diagnostics_accessed_without_active_handler",
|
||||
"20000": "case_not_found",
|
||||
"21000": "cardinality_violation",
|
||||
"22000": "data_exception",
|
||||
"2202E": "array_subscript_error",
|
||||
"22021": "character_not_in_repertoire",
|
||||
"22008": "datetime_field_overflow",
|
||||
"22012": "division_by_zero",
|
||||
"22005": "error_in_assignment",
|
||||
"2200B": "escape_character_conflict",
|
||||
"22022": "indicator_overflow",
|
||||
"22015": "interval_field_overflow",
|
||||
"2201E": "invalid_argument_for_logarithm",
|
||||
"22014": "invalid_argument_for_ntile_function",
|
||||
"22016": "invalid_argument_for_nth_value_function",
|
||||
"2201F": "invalid_argument_for_power_function",
|
||||
"2201G": "invalid_argument_for_width_bucket_function",
|
||||
"22018": "invalid_character_value_for_cast",
|
||||
"22007": "invalid_datetime_format",
|
||||
"22019": "invalid_escape_character",
|
||||
"2200D": "invalid_escape_octet",
|
||||
"22025": "invalid_escape_sequence",
|
||||
"22P06": "nonstandard_use_of_escape_character",
|
||||
"22010": "invalid_indicator_parameter_value",
|
||||
"22023": "invalid_parameter_value",
|
||||
"2201B": "invalid_regular_expression",
|
||||
"2201W": "invalid_row_count_in_limit_clause",
|
||||
"2201X": "invalid_row_count_in_result_offset_clause",
|
||||
"2202H": "invalid_tablesample_argument",
|
||||
"2202G": "invalid_tablesample_repeat",
|
||||
"22009": "invalid_time_zone_displacement_value",
|
||||
"2200C": "invalid_use_of_escape_character",
|
||||
"2200G": "most_specific_type_mismatch",
|
||||
"22004": "null_value_not_allowed",
|
||||
"22002": "null_value_no_indicator_parameter",
|
||||
"22003": "numeric_value_out_of_range",
|
||||
"2200H": "sequence_generator_limit_exceeded",
|
||||
"22026": "string_data_length_mismatch",
|
||||
"22001": "string_data_right_truncation",
|
||||
"22011": "substring_error",
|
||||
"22027": "trim_error",
|
||||
"22024": "unterminated_c_string",
|
||||
"2200F": "zero_length_character_string",
|
||||
"22P01": "floating_point_exception",
|
||||
"22P02": "invalid_text_representation",
|
||||
"22P03": "invalid_binary_representation",
|
||||
"22P04": "bad_copy_file_format",
|
||||
"22P05": "untranslatable_character",
|
||||
"2200L": "not_an_xml_document",
|
||||
"2200M": "invalid_xml_document",
|
||||
"2200N": "invalid_xml_content",
|
||||
"2200S": "invalid_xml_comment",
|
||||
"2200T": "invalid_xml_processing_instruction",
|
||||
"23000": "integrity_constraint_violation",
|
||||
"23001": "restrict_violation",
|
||||
"23502": "not_null_violation",
|
||||
"23503": "foreign_key_violation",
|
||||
"23505": "unique_violation",
|
||||
"23514": "check_violation",
|
||||
"23P01": "exclusion_violation",
|
||||
"24000": "invalid_cursor_state",
|
||||
"25000": "invalid_transaction_state",
|
||||
"25001": "active_sql_transaction",
|
||||
"25002": "branch_transaction_already_active",
|
||||
"25008": "held_cursor_requires_same_isolation_level",
|
||||
"25003": "inappropriate_access_mode_for_branch_transaction",
|
||||
"25004": "inappropriate_isolation_level_for_branch_transaction",
|
||||
"25005": "no_active_sql_transaction_for_branch_transaction",
|
||||
"25006": "read_only_sql_transaction",
|
||||
"25007": "schema_and_data_statement_mixing_not_supported",
|
||||
"25P01": "no_active_sql_transaction",
|
||||
"25P02": "in_failed_sql_transaction",
|
||||
"25P03": "idle_in_transaction_session_timeout",
|
||||
"26000": "invalid_sql_statement_name",
|
||||
"27000": "triggered_data_change_violation",
|
||||
"28000": "invalid_authorization_specification",
|
||||
"28P01": "invalid_password",
|
||||
"2B000": "dependent_privilege_descriptors_still_exist",
|
||||
"2BP01": "dependent_objects_still_exist",
|
||||
"2D000": "invalid_transaction_termination",
|
||||
"2F000": "sql_routine_exception",
|
||||
"2F005": "function_executed_no_return_statement",
|
||||
"2F002": "modifying_sql_data_not_permitted",
|
||||
"2F003": "prohibited_sql_statement_attempted",
|
||||
"2F004": "reading_sql_data_not_permitted",
|
||||
"34000": "invalid_cursor_name",
|
||||
"38000": "external_routine_exception",
|
||||
"38001": "containing_sql_not_permitted",
|
||||
"38002": "modifying_sql_data_not_permitted",
|
||||
"38003": "prohibited_sql_statement_attempted",
|
||||
"38004": "reading_sql_data_not_permitted",
|
||||
"39000": "external_routine_invocation_exception",
|
||||
"39001": "invalid_sqlstate_returned",
|
||||
"39004": "null_value_not_allowed",
|
||||
"39P01": "trigger_protocol_violated",
|
||||
"39P02": "srf_protocol_violated",
|
||||
"39P03": "event_trigger_protocol_violated",
|
||||
"3B000": "savepoint_exception",
|
||||
"3B001": "invalid_savepoint_specification",
|
||||
"3D000": "invalid_catalog_name",
|
||||
"3F000": "invalid_schema_name",
|
||||
"40000": "transaction_rollback",
|
||||
"40002": "transaction_integrity_constraint_violation",
|
||||
"40001": "serialization_failure",
|
||||
"40003": "statement_completion_unknown",
|
||||
"40P01": "deadlock_detected",
|
||||
"42000": "syntax_error_or_access_rule_violation",
|
||||
"42601": "syntax_error",
|
||||
"42501": "insufficient_privilege",
|
||||
"42846": "cannot_coerce",
|
||||
"42803": "grouping_error",
|
||||
"42P20": "windowing_error",
|
||||
"42P19": "invalid_recursion",
|
||||
"42830": "invalid_foreign_key",
|
||||
"42602": "invalid_name",
|
||||
"42622": "name_too_long",
|
||||
"42939": "reserved_name",
|
||||
"42804": "datatype_mismatch",
|
||||
"42P18": "indeterminate_datatype",
|
||||
"42P21": "collation_mismatch",
|
||||
"42P22": "indeterminate_collation",
|
||||
"42809": "wrong_object_type",
|
||||
"428C9": "generated_always",
|
||||
"42703": "undefined_column",
|
||||
"42883": "undefined_function",
|
||||
"42P01": "undefined_table",
|
||||
"42P02": "undefined_parameter",
|
||||
"42704": "undefined_object",
|
||||
"42701": "duplicate_column",
|
||||
"42P03": "duplicate_cursor",
|
||||
"42P04": "duplicate_database",
|
||||
"42723": "duplicate_function",
|
||||
"42P05": "duplicate_prepared_statement",
|
||||
"42P06": "duplicate_schema",
|
||||
"42P07": "duplicate_table",
|
||||
"42712": "duplicate_alias",
|
||||
"42710": "duplicate_object",
|
||||
"42702": "ambiguous_column",
|
||||
"42725": "ambiguous_function",
|
||||
"42P08": "ambiguous_parameter",
|
||||
"42P09": "ambiguous_alias",
|
||||
"42P10": "invalid_column_reference",
|
||||
"42611": "invalid_column_definition",
|
||||
"42P11": "invalid_cursor_definition",
|
||||
"42P12": "invalid_database_definition",
|
||||
"42P13": "invalid_function_definition",
|
||||
"42P14": "invalid_prepared_statement_definition",
|
||||
"42P15": "invalid_schema_definition",
|
||||
"42P16": "invalid_table_definition",
|
||||
"42P17": "invalid_object_definition",
|
||||
"44000": "with_check_option_violation",
|
||||
"53000": "insufficient_resources",
|
||||
"53100": "disk_full",
|
||||
"53200": "out_of_memory",
|
||||
"53300": "too_many_connections",
|
||||
"53400": "configuration_limit_exceeded",
|
||||
"54000": "program_limit_exceeded",
|
||||
"54001": "statement_too_complex",
|
||||
"54011": "too_many_columns",
|
||||
"54023": "too_many_arguments",
|
||||
"55000": "object_not_in_prerequisite_state",
|
||||
"55006": "object_in_use",
|
||||
"55P02": "cant_change_runtime_param",
|
||||
"55P03": "lock_not_available",
|
||||
"57000": "operator_intervention",
|
||||
"57014": "query_canceled",
|
||||
"57P01": "admin_shutdown",
|
||||
"57P02": "crash_shutdown",
|
||||
"57P03": "cannot_connect_now",
|
||||
"57P04": "database_dropped",
|
||||
"58000": "system_error",
|
||||
"58030": "io_error",
|
||||
"58P01": "undefined_file",
|
||||
"58P02": "duplicate_file",
|
||||
"72000": "snapshot_too_old",
|
||||
"F0000": "config_file_error",
|
||||
"F0001": "lock_file_exists",
|
||||
"HV000": "fdw_error",
|
||||
"HV005": "fdw_column_name_not_found",
|
||||
"HV002": "fdw_dynamic_parameter_value_needed",
|
||||
"HV010": "fdw_function_sequence_error",
|
||||
"HV021": "fdw_inconsistent_descriptor_information",
|
||||
"HV024": "fdw_invalid_attribute_value",
|
||||
"HV007": "fdw_invalid_column_name",
|
||||
"HV008": "fdw_invalid_column_number",
|
||||
"HV004": "fdw_invalid_data_type",
|
||||
"HV006": "fdw_invalid_data_type_descriptors",
|
||||
"HV091": "fdw_invalid_descriptor_field_identifier",
|
||||
"HV00B": "fdw_invalid_handle",
|
||||
"HV00C": "fdw_invalid_option_index",
|
||||
"HV00D": "fdw_invalid_option_name",
|
||||
"HV090": "fdw_invalid_string_length_or_buffer_length",
|
||||
"HV00A": "fdw_invalid_string_format",
|
||||
"HV009": "fdw_invalid_use_of_null_pointer",
|
||||
"HV014": "fdw_too_many_handles",
|
||||
"HV001": "fdw_out_of_memory",
|
||||
"HV00P": "fdw_no_schemas",
|
||||
"HV00J": "fdw_option_name_not_found",
|
||||
"HV00K": "fdw_reply_handle",
|
||||
"HV00Q": "fdw_schema_not_found",
|
||||
"HV00R": "fdw_table_not_found",
|
||||
"HV00L": "fdw_unable_to_create_execution",
|
||||
"HV00M": "fdw_unable_to_create_reply",
|
||||
"HV00N": "fdw_unable_to_establish_connection",
|
||||
"P0000": "plpgsql_error",
|
||||
"P0001": "raise_exception",
|
||||
"P0002": "no_data_found",
|
||||
"P0003": "too_many_rows",
|
||||
"P0004": "assert_failure",
|
||||
"XX000": "internal_error",
|
||||
"XX001": "data_corrupted",
|
||||
"XX002": "index_corrupted"
|
||||
}
|
||||
135
worklenz-backend/src/shared/s3.ts
Normal file
135
worklenz-backend/src/shared/s3.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import path from "path";
|
||||
import {
|
||||
DeleteObjectCommand,
|
||||
DeleteObjectCommandInput,
|
||||
GetObjectCommand,
|
||||
ListObjectsV2Command,
|
||||
PutObjectCommand,
|
||||
PutObjectCommandInput,
|
||||
S3Client
|
||||
} from "@aws-sdk/client-s3";
|
||||
import {isProduction, isTestServer, log_error} from "./utils";
|
||||
import {getSignedUrl} from "@aws-sdk/s3-request-presigner";
|
||||
import mime from "mime";
|
||||
|
||||
const {BUCKET, REGION, S3_URL, S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY} = process.env;
|
||||
|
||||
if (!S3_ACCESS_KEY_ID || !S3_SECRET_ACCESS_KEY) {
|
||||
log_error("Invalid S3_ACCESS_KEY_ID or S3_SECRET_ACCESS_KEY. Please check .env file.");
|
||||
}
|
||||
|
||||
const s3Client = new S3Client({
|
||||
region: REGION,
|
||||
credentials: {
|
||||
accessKeyId: S3_ACCESS_KEY_ID as string,
|
||||
secretAccessKey: S3_SECRET_ACCESS_KEY as string,
|
||||
}
|
||||
});
|
||||
|
||||
export function getRootDir() {
|
||||
if (isTestServer()) return "test-server";
|
||||
if (isProduction()) return "secure";
|
||||
return "local-server";
|
||||
}
|
||||
|
||||
export function getKey(teamId: string, projectId: string, attachmentId: string, type: string) {
|
||||
return path.join(getRootDir(), teamId, projectId, `${attachmentId}.${type}`).replace(/\\/g, "/");
|
||||
}
|
||||
|
||||
export function getAvatarKey(userId: string, type: string) {
|
||||
return path.join("avatars", getRootDir(), `${userId}.${type}`).replace(/\\/g, "/");
|
||||
}
|
||||
|
||||
export async function uploadBuffer(buffer: Buffer, type: string, location: string): Promise<string | null> {
|
||||
try {
|
||||
// Set the parameters.
|
||||
const bucketParams: PutObjectCommandInput = {
|
||||
Bucket: BUCKET,
|
||||
// Specify the name of the new object. For example, 'index.html.'
|
||||
// To create a directory for the object, use '/'. For example, 'myApp/package.json'.
|
||||
Key: location,
|
||||
// Content of the new object.
|
||||
Body: buffer,
|
||||
ContentEncoding: "base64",
|
||||
ContentType: type
|
||||
};
|
||||
|
||||
await s3Client.send(new PutObjectCommand(bucketParams));
|
||||
return `${S3_URL}/${location}`;
|
||||
} catch (error) {
|
||||
log_error(error);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function uploadBase64(base64Data: string, location: string) {
|
||||
try {
|
||||
const buffer = Buffer.from(base64Data.replace(/^data:(.*?);base64,/, ""), "base64");
|
||||
const type = base64Data.split(";")[0].split(":")[1] || null;
|
||||
|
||||
if (!type) return null;
|
||||
|
||||
await uploadBuffer(buffer, type, location);
|
||||
return `${S3_URL}/${location}`;
|
||||
} catch (error) {
|
||||
log_error(error);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function deleteObject(key: string) {
|
||||
try {
|
||||
const input: DeleteObjectCommandInput = {
|
||||
Bucket: BUCKET,
|
||||
Key: key,
|
||||
};
|
||||
return await s3Client.send(new DeleteObjectCommand(input));
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function calculateStorage(prefix: string) {
|
||||
try {
|
||||
let totalSize = 0;
|
||||
let continuationToken;
|
||||
let response: any | null = null;
|
||||
|
||||
do {
|
||||
// Use the listObjectsV2 method to list objects in the folder
|
||||
const command: any = new ListObjectsV2Command({
|
||||
Bucket: BUCKET,
|
||||
Prefix: `${getRootDir()}/${prefix}`,
|
||||
ContinuationToken: continuationToken,
|
||||
});
|
||||
response = await s3Client.send(command);
|
||||
|
||||
// Iterate over the objects and add their size to the total
|
||||
if (response?.Contents) {
|
||||
for (const obj of response.Contents) {
|
||||
totalSize += obj.Size;
|
||||
}
|
||||
}
|
||||
|
||||
// If there are more objects to retrieve, set the continuation token
|
||||
continuationToken = response.NextContinuationToken;
|
||||
} while (continuationToken);
|
||||
return totalSize;
|
||||
} catch (error) {
|
||||
log_error(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function createPresignedUrlWithClient(key: string, file: string) {
|
||||
const fileExtension = path.extname(key).toLowerCase();
|
||||
const contentType = mime.lookup(fileExtension);
|
||||
const command = new GetObjectCommand({
|
||||
Bucket: BUCKET,
|
||||
Key: key,
|
||||
ResponseContentType: `${contentType}`,
|
||||
ResponseContentDisposition: `attachment; filename=${file}`,
|
||||
});
|
||||
return getSignedUrl(s3Client, command, {expiresIn: 3600});
|
||||
}
|
||||
10
worklenz-backend/src/shared/safe-controller-function.ts
Normal file
10
worklenz-backend/src/shared/safe-controller-function.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import {NextFunction, Request, Response} from "express";
|
||||
import {IWorkLenzResponse} from "../interfaces/worklenz-response";
|
||||
|
||||
export default (fn: (_req: Request, _res: Response, next: NextFunction) => Promise<IWorkLenzResponse | void>)
|
||||
|
||||
: (req: Request, res: Response, next: NextFunction) => void => {
|
||||
return (req: Request, res: Response, next: NextFunction): void => {
|
||||
void fn(req, res, next);
|
||||
};
|
||||
};
|
||||
35
worklenz-backend/src/shared/slack.ts
Normal file
35
worklenz-backend/src/shared/slack.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import axios from "axios";
|
||||
import {isProduction, log_error} from "./utils";
|
||||
|
||||
export async function send_to_slack(error: any) {
|
||||
if (!isProduction()) return;
|
||||
if (!process.env.SLACK_WEBHOOK) return;
|
||||
try {
|
||||
const url = process.env.SLACK_WEBHOOK;
|
||||
const blocks = [];
|
||||
|
||||
const title = error.message || "Error";
|
||||
|
||||
blocks.push({
|
||||
"type": "header",
|
||||
"text": {
|
||||
"type": "plain_text",
|
||||
"text": title,
|
||||
"emoji": true
|
||||
}
|
||||
});
|
||||
|
||||
blocks.push({
|
||||
type: "section",
|
||||
text: {
|
||||
type: "mrkdwn",
|
||||
text: `\`\`\`\n${JSON.stringify(error)}\`\`\``
|
||||
}
|
||||
});
|
||||
|
||||
const request = {blocks};
|
||||
await axios.post(url, JSON.stringify(request));
|
||||
} catch (e) {
|
||||
log_error(e);
|
||||
}
|
||||
}
|
||||
147
worklenz-backend/src/shared/tasks-controller-utils.ts
Normal file
147
worklenz-backend/src/shared/tasks-controller-utils.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import moment, {max, min} from "moment";
|
||||
import db from "../config/db";
|
||||
import {IGanttDateRange, IGanttWeekRange} from "../interfaces/gantt-chart";
|
||||
|
||||
const monthNames = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
||||
|
||||
export function isWeekend(date: Date) {
|
||||
return date.getDay() == 0 || date.getDay() == 6;
|
||||
}
|
||||
|
||||
export function isSunday(date: Date) {
|
||||
return date.getDay() == 0;
|
||||
}
|
||||
|
||||
export function isLastDayOfWeek(date: Date) {
|
||||
return isSunday(new Date(date));
|
||||
}
|
||||
|
||||
export function isToday(date: Date) {
|
||||
return moment().isSame(moment(date).format("YYYY-MM-DD"), "day");
|
||||
}
|
||||
|
||||
export async function getDates(startDate = "", endDate = "") {
|
||||
let datesToAdd = 21;
|
||||
const currentDuration = moment(endDate).diff(moment(startDate), "days");
|
||||
if (currentDuration < 100) datesToAdd = 100 - currentDuration;
|
||||
|
||||
const start = moment(startDate).subtract("1", "day").format("YYYY-MM-DD");
|
||||
const end = moment(endDate).add(datesToAdd, "days").format("YYYY-MM-DD");
|
||||
|
||||
let dates: IGanttDateRange[] = [];
|
||||
const theDate = new Date(start);
|
||||
|
||||
while (theDate < new Date(end)) {
|
||||
const data: IGanttDateRange = {
|
||||
isSunday: isSunday(theDate),
|
||||
isToday: isToday(theDate),
|
||||
isWeekend: isWeekend(theDate),
|
||||
isLastDayOfWeek: isLastDayOfWeek(theDate),
|
||||
date: new Date(theDate)
|
||||
};
|
||||
dates = [...dates, data];
|
||||
theDate.setDate(theDate.getDate() + 1);
|
||||
}
|
||||
|
||||
dates.splice(-1);
|
||||
return dates;
|
||||
}
|
||||
|
||||
export async function getWeekRange(dates: IGanttDateRange[]) {
|
||||
const weekData: IGanttWeekRange[] = [];
|
||||
const weeks: number[] = [];
|
||||
|
||||
for (const [index, element] of dates.entries()) {
|
||||
const weekIndex = moment(element.date).week();
|
||||
|
||||
if (!weeks.includes(weekIndex)) {
|
||||
const d: any = {};
|
||||
const monthData: string[] = [];
|
||||
d.week_index = weekIndex;
|
||||
d.days_of_week = dates.filter(e => {
|
||||
return moment(e.date).week() === moment(element.date).week();
|
||||
});
|
||||
for (const item of d.days_of_week) {
|
||||
const monthIndex = moment(item.date).month();
|
||||
if (!monthData.includes(monthNames[monthIndex])) monthData.push(monthNames[monthIndex]);
|
||||
}
|
||||
d.month_name = monthData.join(" - ");
|
||||
d.min = dates.findIndex(e => e.date?.valueOf() === min(d.days_of_week.map((days: any) => moment(days.date))).valueOf());
|
||||
d.min = index !== 0 ? d.min + 2 : d.min + 1;
|
||||
d.max = dates.findIndex(e => e.date?.valueOf() === max(d.days_of_week.map((days: any) => moment(days.date))).valueOf()) + 3;
|
||||
|
||||
weeks.push(weekIndex);
|
||||
weekData.push(d);
|
||||
}
|
||||
}
|
||||
return weekData;
|
||||
}
|
||||
|
||||
export async function getMonthRange(dates: any[]) {
|
||||
const monthData = [];
|
||||
const months: any[] = [];
|
||||
for (const [, date] of dates.entries()) {
|
||||
const monthIndex = moment(date.date).month();
|
||||
if (!months.includes(monthIndex)) {
|
||||
const d: any = {};
|
||||
d.month_name = monthNames[monthIndex];
|
||||
d.month_index = monthIndex;
|
||||
d.days_of_month = dates.filter(e => {
|
||||
return moment(e.date).month() === moment(date.date).month();
|
||||
});
|
||||
d.min = dates.findIndex(e => e.date.valueOf() === min(d.days_of_month.map((days: any) => moment(days.date))).valueOf()) + 1;
|
||||
d.max = dates.findIndex(e => e.date.valueOf() === max(d.days_of_month.map((days: any) => moment(days.date))).valueOf()) + 2;
|
||||
|
||||
months.push(monthIndex);
|
||||
monthData.push(d);
|
||||
}
|
||||
}
|
||||
return monthData;
|
||||
}
|
||||
|
||||
export async function getMinMaxOfTaskDates(projectId: string) {
|
||||
const q = `SELECT MIN(start_date) as min_date, MAX(end_date) as max_date
|
||||
FROM tasks
|
||||
WHERE project_id = $1;`;
|
||||
const result = await db.query(q, [projectId]);
|
||||
const [data] = result.rows;
|
||||
|
||||
if (!data.min_date) {
|
||||
const minDateQ = `SELECT MIN(created_at) as min_date
|
||||
FROM tasks
|
||||
WHERE project_id = $1;`;
|
||||
const q1Result = await db.query(minDateQ, [projectId]);
|
||||
const [dataMinDate] = q1Result.rows;
|
||||
data.min_date = dataMinDate?.min_date;
|
||||
}
|
||||
if (!data.max_date) {
|
||||
const maxDateQ = `SELECT MAX(created_at) as max_date
|
||||
FROM tasks
|
||||
WHERE project_id = $1;`;
|
||||
const q1Result = await db.query(maxDateQ, [projectId]);
|
||||
const [dataMaxDate] = q1Result.rows;
|
||||
data.max_date = dataMaxDate?.max_date;
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function getDatesForResourceAllocation(startDate = "", endDate = "") {
|
||||
const end = moment(endDate).add(4, "weeks").format("YYYY-MM-DD");
|
||||
|
||||
let dates: IGanttDateRange[] = [];
|
||||
const theDate = new Date(startDate);
|
||||
|
||||
while (theDate < new Date(end)) {
|
||||
const data: IGanttDateRange = {
|
||||
isSunday: isSunday(theDate),
|
||||
isToday: isToday(theDate),
|
||||
isWeekend: isWeekend(theDate),
|
||||
isLastDayOfWeek: isLastDayOfWeek(theDate),
|
||||
date: new Date(theDate)
|
||||
};
|
||||
dates = [...dates, data];
|
||||
theDate.setDate(theDate.getDate() + 1);
|
||||
}
|
||||
|
||||
return dates;
|
||||
}
|
||||
217
worklenz-backend/src/shared/utils.ts
Normal file
217
worklenz-backend/src/shared/utils.ts
Normal file
@@ -0,0 +1,217 @@
|
||||
import slug from "slugify";
|
||||
import moment from "moment";
|
||||
import lodash from "lodash";
|
||||
import sanitizeHtml from "sanitize-html";
|
||||
|
||||
import { customAlphabet } from "nanoid";
|
||||
import { AvatarNamesMap, NumbersColorMap, WorklenzColorCodes } from "./constants";
|
||||
import { send_to_slack } from "./slack";
|
||||
import { IActivityLogChangeType } from "../services/activity-logs/interfaces";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const error_codes = require("./postgresql-error-codes");
|
||||
|
||||
export function log_error(error: any, user: any | null = null, sendToSlack = true) {
|
||||
const msg = error_codes[error.code];
|
||||
if (msg) {
|
||||
console.log("\n==== BEGIN ERROR ====\n");
|
||||
console.trace(`ERROR [${error.code}]: ${msg}\n`);
|
||||
}
|
||||
|
||||
console.log("\n");
|
||||
console.error(error);
|
||||
console.log("\n==== END ERROR ====\n");
|
||||
|
||||
const err = user ? {
|
||||
user: user || null,
|
||||
error
|
||||
} : error;
|
||||
if (sendToSlack)
|
||||
send_to_slack(err);
|
||||
}
|
||||
|
||||
/** Returns true if node env is production */
|
||||
export function isProduction() {
|
||||
return process.env.NODE_ENV === "production";
|
||||
}
|
||||
|
||||
/** Returns true if uat or dev */
|
||||
export function isTestServer() {
|
||||
const hostname = process.env.HOSTNAME;
|
||||
return hostname === "dev.worklenz.com" || hostname === "uat.app.worklenz.com";
|
||||
}
|
||||
|
||||
/** Returns true if localhost:3000 or localhost:4200 */
|
||||
export function isLocalServer() {
|
||||
const hostname = process.env.HOSTNAME;
|
||||
return hostname === "localhost:4200" || hostname === "localhost:3000" || hostname === "127.0.0.1:3000";
|
||||
}
|
||||
|
||||
/** Returns true of isLocal or isTest server */
|
||||
export function isInternalServer() {
|
||||
return isLocalServer() || isTestServer();
|
||||
}
|
||||
|
||||
/**
|
||||
* String value to a URL-friendly string
|
||||
* @param str {String}
|
||||
* @returns string
|
||||
*/
|
||||
export function slugify(str: string): string {
|
||||
return slug(str || "", {
|
||||
replacement: "-", // replace spaces with replacement
|
||||
remove: /[*+~.()'"!:@]/g, // regex to remove characters
|
||||
lower: true, // result in lower case
|
||||
});
|
||||
}
|
||||
|
||||
export function smallId(len: number) {
|
||||
/**
|
||||
* Create nanoid instance with a specific alphabet
|
||||
* `Alphabet: 0123456789`
|
||||
* @returns e.g. 458652
|
||||
*/
|
||||
return customAlphabet("0123456789", len)();
|
||||
}
|
||||
|
||||
export function isValidateEmail(email: string) {
|
||||
const re =
|
||||
/^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
|
||||
return re.test(String(email).toLowerCase());
|
||||
}
|
||||
|
||||
export function toTsQuery(value: string) {
|
||||
return `${value.replace(/\s/g, "+").replace(/\(|\)/g, "")}:*`;
|
||||
}
|
||||
|
||||
function nextChar(c: string) {
|
||||
return String.fromCharCode(c.charCodeAt(0) + 1);
|
||||
}
|
||||
|
||||
function numberToAlpha(num: number) {
|
||||
if (num < 1 || num > 26) {
|
||||
throw new Error("Number must be between 1 and 26.");
|
||||
}
|
||||
|
||||
// Convert the number to an ASCII code by adding 64
|
||||
const asciiCode = num + 64;
|
||||
|
||||
// Convert the ASCII code to the corresponding character
|
||||
return String.fromCharCode(asciiCode);
|
||||
}
|
||||
|
||||
export function getColor(name?: string, next = false) {
|
||||
const char = name?.replace(/[^a-zA-Z0-9]/g, "").charAt(0).toUpperCase() || "A";
|
||||
|
||||
const map = /\d/.test(char)
|
||||
? NumbersColorMap
|
||||
: AvatarNamesMap;
|
||||
|
||||
return map[next ? nextChar(char) || char : char];
|
||||
}
|
||||
|
||||
export function toMinutes(hours?: number, minutes?: number) {
|
||||
return ~~((hours || 0) * 60) + (minutes || 0);
|
||||
}
|
||||
|
||||
export function toSeconds(hours: number, minutes: number, seconds: number) {
|
||||
return (hours * 3600) + (minutes * 60) + seconds;
|
||||
}
|
||||
|
||||
export function toMilliseconds(hours: number, minutes: number, seconds: number) {
|
||||
return ((hours * 3600) + (minutes * 60) + seconds) * 1000;
|
||||
}
|
||||
|
||||
export function toRound(value: string | number) {
|
||||
return /\d+/.test(value as string)
|
||||
? Math.ceil(+value)
|
||||
: 0;
|
||||
}
|
||||
|
||||
/** Convert bytes to human-readable format (e.g. 1000 bytes - 1 kb) */
|
||||
export function humanFileSize(size: number) {
|
||||
const i = size == 0 ? 0 : ~~(Math.log(size) / Math.log(1024));
|
||||
return `${(+(size / Math.pow(1024, i)).toFixed(2))} ${["B", "KB", "MB", "GB", "TB"][i]}`;
|
||||
}
|
||||
|
||||
export function getRandomColorCode() {
|
||||
// Using bitwise is faster than Math.floor
|
||||
return WorklenzColorCodes[~~(Math.random() * WorklenzColorCodes.length)];
|
||||
}
|
||||
|
||||
export function sanitize(value: string) {
|
||||
if (!value) return "";
|
||||
|
||||
const escapedString = value
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
|
||||
return sanitizeHtml(escapedString);
|
||||
}
|
||||
|
||||
export function escape(value: string) {
|
||||
return lodash.escape(sanitizeHtml(value));
|
||||
}
|
||||
|
||||
export function unescape(value: string) {
|
||||
return lodash.unescape(value);
|
||||
}
|
||||
|
||||
export function isUnicode(value: string) {
|
||||
for (let i = 0, n = value.length; i < n; i++) {
|
||||
if (value.charCodeAt(i) > 255) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function formatDuration(duration: moment.Duration) {
|
||||
const empty = "0h 0m";
|
||||
let format = "";
|
||||
|
||||
if (duration.asMilliseconds() === 0) return empty;
|
||||
|
||||
const h = ~~(duration.asHours());
|
||||
const m = duration.minutes();
|
||||
const s = duration.seconds();
|
||||
|
||||
if (h === 0 && s > 0) {
|
||||
format = `${m}m ${s}s`;
|
||||
} else if (h > 0 && s === 0) {
|
||||
format = `${h}h ${m}m`;
|
||||
} else if (h > 0 && s > 0) {
|
||||
format = `${h}h ${m}m ${s}s`;
|
||||
} else {
|
||||
format = `${h}h ${m}m`;
|
||||
}
|
||||
|
||||
return format;
|
||||
}
|
||||
|
||||
export function calculateMonthDays(startDate: string, endDate: string): string {
|
||||
const start: Date = new Date(startDate);
|
||||
const end: Date = new Date(endDate);
|
||||
|
||||
const diffInMilliseconds: number = Math.abs(end.getTime() - start.getTime());
|
||||
const days: number = Math.floor(diffInMilliseconds / (1000 * 60 * 60 * 24));
|
||||
const months: number = Math.floor(days / 30);
|
||||
const remainingDays: number = days % 30;
|
||||
|
||||
|
||||
return `${months} ${months > 1 ? "months" : "month"} ${remainingDays} ${remainingDays !== 1 ? "days" : "day"}`;
|
||||
}
|
||||
|
||||
export function int<T>(value: T) {
|
||||
return isNaN(+value) ? 0 : +value;
|
||||
}
|
||||
|
||||
export function formatLogText(log: { log_type: IActivityLogChangeType; }) {
|
||||
if (log.log_type === IActivityLogChangeType.ASSIGN) return "added an ";
|
||||
if (log.log_type === IActivityLogChangeType.UNASSIGN) return "removed an ";
|
||||
if (log.log_type === IActivityLogChangeType.UPDATE) return "updated the ";
|
||||
if (log.log_type === IActivityLogChangeType.CREATE) return "added a ";
|
||||
if (log.log_type === IActivityLogChangeType.DELETE) return "removed a ";
|
||||
return log.log_type;
|
||||
}
|
||||
Reference in New Issue
Block a user