Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 87 additions & 0 deletions Modules/Tasks/helpers/taskMongo/relationRules.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
// Task-to-task relation rules. Pure data + validation — no I/O — so the
// route dispatcher, the relations mixin, and the unit tests share one
// source of truth.

const RELATION_TYPES = Object.freeze({
BLOCKS: 'blocks',
BLOCKED_BY: 'blocked_by',
DUPLICATES: 'duplicates',
DUPLICATED_BY: 'duplicated_by',
RELATES_TO: 'relates_to',
});

const RELATION_TYPE_LIST = Object.freeze(Object.values(RELATION_TYPES));

// Every link is stored on BOTH task documents: the initiating side keeps
// `type`, the related side keeps INVERSE_RELATION[type]. `relates_to` is
// symmetric, so it is its own inverse.
const INVERSE_RELATION = Object.freeze({
[RELATION_TYPES.BLOCKS]: RELATION_TYPES.BLOCKED_BY,
[RELATION_TYPES.BLOCKED_BY]: RELATION_TYPES.BLOCKS,
[RELATION_TYPES.DUPLICATES]: RELATION_TYPES.DUPLICATED_BY,
[RELATION_TYPES.DUPLICATED_BY]: RELATION_TYPES.DUPLICATES,
[RELATION_TYPES.RELATES_TO]: RELATION_TYPES.RELATES_TO,
});

// Human wording used in history messages and list responses:
// "<TaskKey> <label> <TaskKey>", e.g. "AH-12 is blocked by AH-34".
const RELATION_LABELS = Object.freeze({
[RELATION_TYPES.BLOCKS]: 'blocks',
[RELATION_TYPES.BLOCKED_BY]: 'is blocked by',
[RELATION_TYPES.DUPLICATES]: 'duplicates',
[RELATION_TYPES.DUPLICATED_BY]: 'is duplicated by',
[RELATION_TYPES.RELATES_TO]: 'relates to',
});

const OBJECT_ID_PATTERN = /^[0-9a-fA-F]{24}$/;

const isObjectIdString = (id) => OBJECT_ID_PATTERN.test(String(id || ''));

/* Validate a single task reference (used by `list`). */
const validateTaskRef = ({ companyId, taskId }) => {
if (!companyId) {
return { valid: false, reason: 'companyId is required.' };
}
if (!isObjectIdString(taskId)) {
return { valid: false, reason: 'A valid taskId is required.' };
}
return { valid: true, reason: '' };
};

/* Validate a pair of tasks (used by `remove`, where type is irrelevant). */
const validateRelationPair = ({ companyId, taskId, relatedTaskId }) => {
const refCheck = validateTaskRef({ companyId, taskId });
if (!refCheck.valid) {
return refCheck;
}
if (!isObjectIdString(relatedTaskId)) {
return { valid: false, reason: 'A valid relatedTaskId is required.' };
}
if (String(taskId) === String(relatedTaskId)) {
return { valid: false, reason: 'A task cannot be linked to itself.' };
}
return { valid: true, reason: '' };
};

/* Validate the full add-relation input (pair + relation type). */
const validateRelationInput = ({ companyId, taskId, relatedTaskId, type }) => {
const pairCheck = validateRelationPair({ companyId, taskId, relatedTaskId });
if (!pairCheck.valid) {
return pairCheck;
}
if (!INVERSE_RELATION[type]) {
return { valid: false, reason: `Unknown relation type "${type}". Allowed: ${RELATION_TYPE_LIST.join(', ')}.` };
}
return { valid: true, reason: '' };
};

module.exports = {
RELATION_TYPES,
RELATION_TYPE_LIST,
INVERSE_RELATION,
RELATION_LABELS,
isObjectIdString,
validateTaskRef,
validateRelationPair,
validateRelationInput,
};
227 changes: 227 additions & 0 deletions Modules/Tasks/helpers/taskMongo/relations.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,227 @@
const logger = require("../../../../Config/loggerConfig");
const { SCHEMA_TYPE } = require('../../../../Config/schemaType');
const { MongoDbCrudOpration } = require("../../../../utils/mongo-handler/mongoQueries");
const { default: mongoose } = require("mongoose");
const socketEmitter = require('../../../../event/socketEventEmitter');
const { HandleHistory } = require("../mongo_helper");
const { INVERSE_RELATION, RELATION_LABELS, validateTaskRef, validateRelationPair, validateRelationInput } = require('./relationRules');

// Task-to-task relations (blocks / blocked_by / duplicates / duplicated_by /
// relates_to). A link is stored on BOTH task documents as an entry in the
// `relations` array — `{ taskId, type, createdBy, createdAt }` — with the
// inverse type on the related side, so reading either task shows the link
// without a join. One link per task pair; changing its type is remove + add.

module.exports = {

/* -------------- ADD A RELATION BETWEEN TWO TASKS -----------------*/
// payload: { companyId, taskId, relatedTaskId, type, userData }
addTaskRelation({ companyId, taskId, relatedTaskId, type, userData }) {
return new Promise(async (resolve, reject) => {
try {
const check = validateRelationInput({ companyId, taskId, relatedTaskId, type });
if (!check.valid) {
return reject(new Error(check.reason));
}

const taskObjId = new mongoose.Types.ObjectId(taskId);
const relatedObjId = new mongoose.Types.ObjectId(relatedTaskId);
const [task, relatedTask] = await Promise.all([
this.findRelationTask(companyId, taskObjId),
this.findRelationTask(companyId, relatedObjId),
]);
if (!task || !relatedTask) {
return reject(new Error('Task not found.'));
}
const alreadyLinked = (task.relations || []).some((rel) => String(rel.taskId) === String(relatedTaskId));
if (alreadyLinked) {
return reject(new Error(`Tasks ${task.TaskKey} and ${relatedTask.TaskKey} are already linked.`));
}

const createdAt = new Date();
const createdBy = userData && (userData.id || userData._id) ? String(userData.id || userData._id) : '';
const inverseType = INVERSE_RELATION[type];

const [updatedTask, updatedRelated] = await Promise.all([
this.pushRelationEntry(companyId, taskObjId, { taskId: relatedObjId, type, createdBy, createdAt }),
this.pushRelationEntry(companyId, relatedObjId, { taskId: taskObjId, type: inverseType, createdBy, createdAt }),
]);

socketEmitter.emit('update', { type: "update", data: updatedTask, updatedFields: { relations: updatedTask?.relations || [] }, module: 'task' });
socketEmitter.emit('update', { type: "update", data: updatedRelated, updatedFields: { relations: updatedRelated?.relations || [] }, module: 'task' });

this.addRelationHistory({ companyId, task, otherKey: relatedTask.TaskKey, type, userData });
this.addRelationHistory({ companyId, task: relatedTask, otherKey: task.TaskKey, type: inverseType, userData });

resolve({
status: true,
statusText: `${task.TaskKey} now ${RELATION_LABELS[type]} ${relatedTask.TaskKey}.`,
data: { taskId, relatedTaskId, type, relations: updatedTask?.relations || [] },
});
} catch (error) {
logger.error(`ERROR in add task relation: ${error.message}`);
reject(error);
}
})
},

/* -------------- REMOVE A RELATION BETWEEN TWO TASKS -----------------*/
// payload: { companyId, taskId, relatedTaskId, userData }
removeTaskRelation({ companyId, taskId, relatedTaskId, userData }) {
return new Promise(async (resolve, reject) => {
try {
const check = validateRelationPair({ companyId, taskId, relatedTaskId });
if (!check.valid) {
return reject(new Error(check.reason));
}

const taskObjId = new mongoose.Types.ObjectId(taskId);
const relatedObjId = new mongoose.Types.ObjectId(relatedTaskId);
const [task, relatedTask] = await Promise.all([
this.findRelationTask(companyId, taskObjId),
this.findRelationTask(companyId, relatedObjId),
]);
if (!task) {
return reject(new Error('Task not found.'));
}
const existing = (task.relations || []).some((rel) => String(rel.taskId) === String(relatedTaskId));
if (!existing) {
return reject(new Error('These tasks are not linked.'));
}

// Pull both sides. The related task may have been deleted in
// the meantime — pulling from a missing document is a no-op.
const [updatedTask, updatedRelated] = await Promise.all([
this.pullRelationEntry(companyId, taskObjId, relatedObjId),
this.pullRelationEntry(companyId, relatedObjId, taskObjId),
]);

if (updatedTask) {
socketEmitter.emit('update', { type: "update", data: updatedTask, updatedFields: { relations: updatedTask.relations || [] }, module: 'task' });
}
if (updatedRelated) {
socketEmitter.emit('update', { type: "update", data: updatedRelated, updatedFields: { relations: updatedRelated.relations || [] }, module: 'task' });
}

this.removeRelationHistory({ companyId, task, otherKey: relatedTask ? relatedTask.TaskKey : 'a deleted task', userData });
if (relatedTask) {
this.removeRelationHistory({ companyId, task: relatedTask, otherKey: task.TaskKey, userData });
}

resolve({
status: true,
statusText: 'Task link removed successfully.',
data: { taskId, relatedTaskId, relations: updatedTask?.relations || [] },
});
} catch (error) {
logger.error(`ERROR in remove task relation: ${error.message}`);
reject(error);
}
})
},

/* -------------- LIST RELATIONS OF A TASK (WITH TASK SUMMARIES) -----------------*/
// payload: { companyId, taskId }
getTaskRelations({ companyId, taskId }) {
return new Promise(async (resolve, reject) => {
try {
const check = validateTaskRef({ companyId, taskId });
if (!check.valid) {
return reject(new Error(check.reason));
}

const task = await this.findRelationTask(companyId, new mongoose.Types.ObjectId(taskId));
if (!task) {
return reject(new Error('Task not found.'));
}
const relations = task.relations || [];
if (!relations.length) {
return resolve({ status: true, statusText: 'No linked tasks.', data: [] });
}

const relatedIds = relations.map((rel) => new mongoose.Types.ObjectId(rel.taskId));
const summaryQuery = {
type: SCHEMA_TYPE.TASKS,
data: [
{ _id: { $in: relatedIds } },
'TaskName TaskKey status statusKey statusType ProjectID sprintId folderObjId AssigneeUserId deletedStatusKey isParentTask Task_Priority DueDate',
],
};
const relatedDocs = await MongoDbCrudOpration(companyId, summaryQuery, 'find');
const docById = new Map((relatedDocs || []).map((doc) => [String(doc._id), doc]));

const data = relations.map((rel) => ({
taskId: String(rel.taskId),
type: rel.type,
label: RELATION_LABELS[rel.type] || rel.type,
createdBy: rel.createdBy || '',
createdAt: rel.createdAt || null,
task: docById.get(String(rel.taskId)) || null,
}));

resolve({ status: true, statusText: 'Linked tasks fetched successfully.', data });
} catch (error) {
logger.error(`ERROR in get task relations: ${error.message}`);
reject(error);
}
})
},

/* -------------- INTERNAL HELPERS (RELATIONS) -----------------*/

findRelationTask(companyId, taskObjId) {
const query = {
type: SCHEMA_TYPE.TASKS,
data: [{ _id: taskObjId, deletedStatusKey: { $ne: 1 } }],
};
return MongoDbCrudOpration(companyId, query, 'findOne');
},

pushRelationEntry(companyId, taskObjId, entry) {
const query = {
type: SCHEMA_TYPE.TASKS,
data: [
{ _id: taskObjId },
{ $push: { relations: entry } },
{ returnDocument: 'after' },
],
};
return MongoDbCrudOpration(companyId, query, 'findOneAndUpdate');
},

pullRelationEntry(companyId, taskObjId, relatedObjId) {
const query = {
type: SCHEMA_TYPE.TASKS,
data: [
{ _id: taskObjId },
{ $pull: { relations: { taskId: relatedObjId } } },
{ returnDocument: 'after' },
],
};
return MongoDbCrudOpration(companyId, query, 'findOneAndUpdate');
},

addRelationHistory({ companyId, task, otherKey, type, userData }) {
const historyObj = {
key: 'Task_Relation',
message: `<b>${userData?.Employee_Name || 'Someone'}</b> has linked this task — it now <b>${RELATION_LABELS[type]}</b> <b>${otherKey}</b>.`,
sprintId: task.sprintId,
};
HandleHistory('task', companyId, task.ProjectID, task._id, historyObj, userData)
.catch((error) => {
logger.error(`ERROR in task relation history: ${error.message}`);
});
},

removeRelationHistory({ companyId, task, otherKey, userData }) {
const historyObj = {
key: 'Task_Relation',
message: `<b>${userData?.Employee_Name || 'Someone'}</b> has removed the link with <b>${otherKey}</b>.`,
sprintId: task.sprintId,
};
HandleHistory('task', companyId, task.ProjectID, task._id, historyObj, userData)
.catch((error) => {
logger.error(`ERROR in task relation history: ${error.message}`);
});
},
};
2 changes: 2 additions & 0 deletions Modules/Tasks/helpers/task_class_Mongo.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ const structuralMixin = require('./taskMongo/structural');
const mergeDuplicateMixin = require('./taskMongo/mergeDuplicate');
const internalsMixin = require('./taskMongo/internals');
const bulkMixin = require('./taskMongo/bulk');
const relationsMixin = require('./taskMongo/relations');

class Task {}

Expand All @@ -24,6 +25,7 @@ Object.assign(
mergeDuplicateMixin,
internalsMixin,
bulkMixin,
relationsMixin,
);

exports.taskMongo = new Task();
32 changes: 32 additions & 0 deletions Modules/Tasks/routes.js
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,38 @@ exports.init = (app) => {
}
});

// Task-to-task relations (blocks / blocked_by / duplicates / duplicated_by
// / relates_to). Single endpoint, explicit action allowlist — same dispatch
// + verified-header companyId convention as POST /api/v2/tasks/bulk above.
// Body: { action: 'add' | 'remove' | 'list', taskId, relatedTaskId?, type?, userData? }
app.post('/api/v2/tasks/relations', (req, res) => {
try {
const RELATION_ACTIONS = {
add: 'addTaskRelation',
remove: 'removeTaskRelation',
list: 'getTaskRelations',
};
const method = RELATION_ACTIONS[req.body && req.body.action];
if (!method) {
return res.send({ status: false, statusText: 'Invalid relation action' });
}
const headerCompanyId = req.headers['companyid'] || '';
const payload = { ...req.body, companyId: headerCompanyId };

taskMongo[method](payload)
.then((response) => {
res.send(response);
})
.catch((error) => {
logger.error(`ERROR relation ${req.body.action}: ${error.message}`);
res.send({ status: false, statusText: error.message });
});
} catch (error) {
logger.error(`ERROR relation dispatch: ${error.message}`);
res.send({ status: false, statusText: error.message });
}
});

app.patch('/api/v1/importTasks', (req, res) => {
taskMongo.createMultipleTasks(req.body)
.then((response) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@
:subTasksArray="subTasksArray"
:isMainSpinner="isMainSpinner"
/>
<LinkedTasks
:task="task"
class="mt-1"
/>
<div class="position-re">
<div v-if="checkPermission('task.task_custom_field',projectData?.isGlobalPermission) !== null && checkApps('CustomFields')">
<div :class="[{'pointer-event-none opacity-5 blur-3-px':!currentCompany?.planFeature?.customFields}]">
Expand Down Expand Up @@ -109,6 +113,7 @@ import Description from '@/components/atom/Description/Description.vue'
import Attachments from '@/components/atom/Attachments/Attachments.vue'
import CheckListComponent from '@/components/molecules/CheckList/CheckList.vue'
import SubTasks from '@/components/organisms/SubTasks/SubTasks.vue'
import LinkedTasks from '@/components/organisms/LinkedTasks/LinkedTasks.vue'
import CreateTagPopup from "@/components/molecules/TagList/CreateTagPopup.vue";
import TagChip from '@/components/atom/TagChip/TagChip.vue'
import PromptSidebar from "@/components/molecules/PromptSidebar/PromptSidebar.vue";
Expand Down
Loading
Loading