Skip to content

Commit 866d8fa

Browse files
parth0025claude
andauthored
feat(tasks): add task-to-task relations (blocks / duplicates / relates) (#221)
* feat(tasks): add task-to-task relations (blocks / duplicates / relates) Adds linked-task support across the backend: - POST /api/v2/tasks/relations - add | remove | list actions behind an explicit allowlist, with companyId taken from the verified header (same convention as POST /api/v2/tasks/bulk) - relations mixin on taskMongo: a link is stored on BOTH task documents with the inverse type (blocks <-> blocked_by, duplicates <-> duplicated_by, relates_to is symmetric), so either side reads its links without a join; one link per task pair - socket emits for both updated tasks and activity history on both sides (messages use TaskKeys, not raw task names) - pure rules module (relationRules.js) shared by route, mixin, and tests; 17 unit tests in tests/task-relations-rules.test.js Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(tasks): add linked tasks section to task detail - new LinkedTasks.vue rendered below Subtasks: lists relations with type label, TaskKey, name and status chip; add-link flow with a relation-type dropdown and debounced same-project task search; live refresh via the projectData/gettaskDetailData socket watcher - task search wraps ProjectID in the backend objId marker (the field is an ObjectId and aggregate $match skips mongoose casting) - declare `relations` on the strict tasks schema so $push persists (P1-SEC-11 strict mode silently drops undeclared update paths) - 15 new en locale keys under Projects.*; other languages fall back to en per the i18n fallbackLocale config Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 6d3f34d commit 866d8fa

9 files changed

Lines changed: 850 additions & 0 deletions

File tree

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
// Task-to-task relation rules. Pure data + validation — no I/O — so the
2+
// route dispatcher, the relations mixin, and the unit tests share one
3+
// source of truth.
4+
5+
const RELATION_TYPES = Object.freeze({
6+
BLOCKS: 'blocks',
7+
BLOCKED_BY: 'blocked_by',
8+
DUPLICATES: 'duplicates',
9+
DUPLICATED_BY: 'duplicated_by',
10+
RELATES_TO: 'relates_to',
11+
});
12+
13+
const RELATION_TYPE_LIST = Object.freeze(Object.values(RELATION_TYPES));
14+
15+
// Every link is stored on BOTH task documents: the initiating side keeps
16+
// `type`, the related side keeps INVERSE_RELATION[type]. `relates_to` is
17+
// symmetric, so it is its own inverse.
18+
const INVERSE_RELATION = Object.freeze({
19+
[RELATION_TYPES.BLOCKS]: RELATION_TYPES.BLOCKED_BY,
20+
[RELATION_TYPES.BLOCKED_BY]: RELATION_TYPES.BLOCKS,
21+
[RELATION_TYPES.DUPLICATES]: RELATION_TYPES.DUPLICATED_BY,
22+
[RELATION_TYPES.DUPLICATED_BY]: RELATION_TYPES.DUPLICATES,
23+
[RELATION_TYPES.RELATES_TO]: RELATION_TYPES.RELATES_TO,
24+
});
25+
26+
// Human wording used in history messages and list responses:
27+
// "<TaskKey> <label> <TaskKey>", e.g. "AH-12 is blocked by AH-34".
28+
const RELATION_LABELS = Object.freeze({
29+
[RELATION_TYPES.BLOCKS]: 'blocks',
30+
[RELATION_TYPES.BLOCKED_BY]: 'is blocked by',
31+
[RELATION_TYPES.DUPLICATES]: 'duplicates',
32+
[RELATION_TYPES.DUPLICATED_BY]: 'is duplicated by',
33+
[RELATION_TYPES.RELATES_TO]: 'relates to',
34+
});
35+
36+
const OBJECT_ID_PATTERN = /^[0-9a-fA-F]{24}$/;
37+
38+
const isObjectIdString = (id) => OBJECT_ID_PATTERN.test(String(id || ''));
39+
40+
/* Validate a single task reference (used by `list`). */
41+
const validateTaskRef = ({ companyId, taskId }) => {
42+
if (!companyId) {
43+
return { valid: false, reason: 'companyId is required.' };
44+
}
45+
if (!isObjectIdString(taskId)) {
46+
return { valid: false, reason: 'A valid taskId is required.' };
47+
}
48+
return { valid: true, reason: '' };
49+
};
50+
51+
/* Validate a pair of tasks (used by `remove`, where type is irrelevant). */
52+
const validateRelationPair = ({ companyId, taskId, relatedTaskId }) => {
53+
const refCheck = validateTaskRef({ companyId, taskId });
54+
if (!refCheck.valid) {
55+
return refCheck;
56+
}
57+
if (!isObjectIdString(relatedTaskId)) {
58+
return { valid: false, reason: 'A valid relatedTaskId is required.' };
59+
}
60+
if (String(taskId) === String(relatedTaskId)) {
61+
return { valid: false, reason: 'A task cannot be linked to itself.' };
62+
}
63+
return { valid: true, reason: '' };
64+
};
65+
66+
/* Validate the full add-relation input (pair + relation type). */
67+
const validateRelationInput = ({ companyId, taskId, relatedTaskId, type }) => {
68+
const pairCheck = validateRelationPair({ companyId, taskId, relatedTaskId });
69+
if (!pairCheck.valid) {
70+
return pairCheck;
71+
}
72+
if (!INVERSE_RELATION[type]) {
73+
return { valid: false, reason: `Unknown relation type "${type}". Allowed: ${RELATION_TYPE_LIST.join(', ')}.` };
74+
}
75+
return { valid: true, reason: '' };
76+
};
77+
78+
module.exports = {
79+
RELATION_TYPES,
80+
RELATION_TYPE_LIST,
81+
INVERSE_RELATION,
82+
RELATION_LABELS,
83+
isObjectIdString,
84+
validateTaskRef,
85+
validateRelationPair,
86+
validateRelationInput,
87+
};
Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
1+
const logger = require("../../../../Config/loggerConfig");
2+
const { SCHEMA_TYPE } = require('../../../../Config/schemaType');
3+
const { MongoDbCrudOpration } = require("../../../../utils/mongo-handler/mongoQueries");
4+
const { default: mongoose } = require("mongoose");
5+
const socketEmitter = require('../../../../event/socketEventEmitter');
6+
const { HandleHistory } = require("../mongo_helper");
7+
const { INVERSE_RELATION, RELATION_LABELS, validateTaskRef, validateRelationPair, validateRelationInput } = require('./relationRules');
8+
9+
// Task-to-task relations (blocks / blocked_by / duplicates / duplicated_by /
10+
// relates_to). A link is stored on BOTH task documents as an entry in the
11+
// `relations` array — `{ taskId, type, createdBy, createdAt }` — with the
12+
// inverse type on the related side, so reading either task shows the link
13+
// without a join. One link per task pair; changing its type is remove + add.
14+
15+
module.exports = {
16+
17+
/* -------------- ADD A RELATION BETWEEN TWO TASKS -----------------*/
18+
// payload: { companyId, taskId, relatedTaskId, type, userData }
19+
addTaskRelation({ companyId, taskId, relatedTaskId, type, userData }) {
20+
return new Promise(async (resolve, reject) => {
21+
try {
22+
const check = validateRelationInput({ companyId, taskId, relatedTaskId, type });
23+
if (!check.valid) {
24+
return reject(new Error(check.reason));
25+
}
26+
27+
const taskObjId = new mongoose.Types.ObjectId(taskId);
28+
const relatedObjId = new mongoose.Types.ObjectId(relatedTaskId);
29+
const [task, relatedTask] = await Promise.all([
30+
this.findRelationTask(companyId, taskObjId),
31+
this.findRelationTask(companyId, relatedObjId),
32+
]);
33+
if (!task || !relatedTask) {
34+
return reject(new Error('Task not found.'));
35+
}
36+
const alreadyLinked = (task.relations || []).some((rel) => String(rel.taskId) === String(relatedTaskId));
37+
if (alreadyLinked) {
38+
return reject(new Error(`Tasks ${task.TaskKey} and ${relatedTask.TaskKey} are already linked.`));
39+
}
40+
41+
const createdAt = new Date();
42+
const createdBy = userData && (userData.id || userData._id) ? String(userData.id || userData._id) : '';
43+
const inverseType = INVERSE_RELATION[type];
44+
45+
const [updatedTask, updatedRelated] = await Promise.all([
46+
this.pushRelationEntry(companyId, taskObjId, { taskId: relatedObjId, type, createdBy, createdAt }),
47+
this.pushRelationEntry(companyId, relatedObjId, { taskId: taskObjId, type: inverseType, createdBy, createdAt }),
48+
]);
49+
50+
socketEmitter.emit('update', { type: "update", data: updatedTask, updatedFields: { relations: updatedTask?.relations || [] }, module: 'task' });
51+
socketEmitter.emit('update', { type: "update", data: updatedRelated, updatedFields: { relations: updatedRelated?.relations || [] }, module: 'task' });
52+
53+
this.addRelationHistory({ companyId, task, otherKey: relatedTask.TaskKey, type, userData });
54+
this.addRelationHistory({ companyId, task: relatedTask, otherKey: task.TaskKey, type: inverseType, userData });
55+
56+
resolve({
57+
status: true,
58+
statusText: `${task.TaskKey} now ${RELATION_LABELS[type]} ${relatedTask.TaskKey}.`,
59+
data: { taskId, relatedTaskId, type, relations: updatedTask?.relations || [] },
60+
});
61+
} catch (error) {
62+
logger.error(`ERROR in add task relation: ${error.message}`);
63+
reject(error);
64+
}
65+
})
66+
},
67+
68+
/* -------------- REMOVE A RELATION BETWEEN TWO TASKS -----------------*/
69+
// payload: { companyId, taskId, relatedTaskId, userData }
70+
removeTaskRelation({ companyId, taskId, relatedTaskId, userData }) {
71+
return new Promise(async (resolve, reject) => {
72+
try {
73+
const check = validateRelationPair({ companyId, taskId, relatedTaskId });
74+
if (!check.valid) {
75+
return reject(new Error(check.reason));
76+
}
77+
78+
const taskObjId = new mongoose.Types.ObjectId(taskId);
79+
const relatedObjId = new mongoose.Types.ObjectId(relatedTaskId);
80+
const [task, relatedTask] = await Promise.all([
81+
this.findRelationTask(companyId, taskObjId),
82+
this.findRelationTask(companyId, relatedObjId),
83+
]);
84+
if (!task) {
85+
return reject(new Error('Task not found.'));
86+
}
87+
const existing = (task.relations || []).some((rel) => String(rel.taskId) === String(relatedTaskId));
88+
if (!existing) {
89+
return reject(new Error('These tasks are not linked.'));
90+
}
91+
92+
// Pull both sides. The related task may have been deleted in
93+
// the meantime — pulling from a missing document is a no-op.
94+
const [updatedTask, updatedRelated] = await Promise.all([
95+
this.pullRelationEntry(companyId, taskObjId, relatedObjId),
96+
this.pullRelationEntry(companyId, relatedObjId, taskObjId),
97+
]);
98+
99+
if (updatedTask) {
100+
socketEmitter.emit('update', { type: "update", data: updatedTask, updatedFields: { relations: updatedTask.relations || [] }, module: 'task' });
101+
}
102+
if (updatedRelated) {
103+
socketEmitter.emit('update', { type: "update", data: updatedRelated, updatedFields: { relations: updatedRelated.relations || [] }, module: 'task' });
104+
}
105+
106+
this.removeRelationHistory({ companyId, task, otherKey: relatedTask ? relatedTask.TaskKey : 'a deleted task', userData });
107+
if (relatedTask) {
108+
this.removeRelationHistory({ companyId, task: relatedTask, otherKey: task.TaskKey, userData });
109+
}
110+
111+
resolve({
112+
status: true,
113+
statusText: 'Task link removed successfully.',
114+
data: { taskId, relatedTaskId, relations: updatedTask?.relations || [] },
115+
});
116+
} catch (error) {
117+
logger.error(`ERROR in remove task relation: ${error.message}`);
118+
reject(error);
119+
}
120+
})
121+
},
122+
123+
/* -------------- LIST RELATIONS OF A TASK (WITH TASK SUMMARIES) -----------------*/
124+
// payload: { companyId, taskId }
125+
getTaskRelations({ companyId, taskId }) {
126+
return new Promise(async (resolve, reject) => {
127+
try {
128+
const check = validateTaskRef({ companyId, taskId });
129+
if (!check.valid) {
130+
return reject(new Error(check.reason));
131+
}
132+
133+
const task = await this.findRelationTask(companyId, new mongoose.Types.ObjectId(taskId));
134+
if (!task) {
135+
return reject(new Error('Task not found.'));
136+
}
137+
const relations = task.relations || [];
138+
if (!relations.length) {
139+
return resolve({ status: true, statusText: 'No linked tasks.', data: [] });
140+
}
141+
142+
const relatedIds = relations.map((rel) => new mongoose.Types.ObjectId(rel.taskId));
143+
const summaryQuery = {
144+
type: SCHEMA_TYPE.TASKS,
145+
data: [
146+
{ _id: { $in: relatedIds } },
147+
'TaskName TaskKey status statusKey statusType ProjectID sprintId folderObjId AssigneeUserId deletedStatusKey isParentTask Task_Priority DueDate',
148+
],
149+
};
150+
const relatedDocs = await MongoDbCrudOpration(companyId, summaryQuery, 'find');
151+
const docById = new Map((relatedDocs || []).map((doc) => [String(doc._id), doc]));
152+
153+
const data = relations.map((rel) => ({
154+
taskId: String(rel.taskId),
155+
type: rel.type,
156+
label: RELATION_LABELS[rel.type] || rel.type,
157+
createdBy: rel.createdBy || '',
158+
createdAt: rel.createdAt || null,
159+
task: docById.get(String(rel.taskId)) || null,
160+
}));
161+
162+
resolve({ status: true, statusText: 'Linked tasks fetched successfully.', data });
163+
} catch (error) {
164+
logger.error(`ERROR in get task relations: ${error.message}`);
165+
reject(error);
166+
}
167+
})
168+
},
169+
170+
/* -------------- INTERNAL HELPERS (RELATIONS) -----------------*/
171+
172+
findRelationTask(companyId, taskObjId) {
173+
const query = {
174+
type: SCHEMA_TYPE.TASKS,
175+
data: [{ _id: taskObjId, deletedStatusKey: { $ne: 1 } }],
176+
};
177+
return MongoDbCrudOpration(companyId, query, 'findOne');
178+
},
179+
180+
pushRelationEntry(companyId, taskObjId, entry) {
181+
const query = {
182+
type: SCHEMA_TYPE.TASKS,
183+
data: [
184+
{ _id: taskObjId },
185+
{ $push: { relations: entry } },
186+
{ returnDocument: 'after' },
187+
],
188+
};
189+
return MongoDbCrudOpration(companyId, query, 'findOneAndUpdate');
190+
},
191+
192+
pullRelationEntry(companyId, taskObjId, relatedObjId) {
193+
const query = {
194+
type: SCHEMA_TYPE.TASKS,
195+
data: [
196+
{ _id: taskObjId },
197+
{ $pull: { relations: { taskId: relatedObjId } } },
198+
{ returnDocument: 'after' },
199+
],
200+
};
201+
return MongoDbCrudOpration(companyId, query, 'findOneAndUpdate');
202+
},
203+
204+
addRelationHistory({ companyId, task, otherKey, type, userData }) {
205+
const historyObj = {
206+
key: 'Task_Relation',
207+
message: `<b>${userData?.Employee_Name || 'Someone'}</b> has linked this task — it now <b>${RELATION_LABELS[type]}</b> <b>${otherKey}</b>.`,
208+
sprintId: task.sprintId,
209+
};
210+
HandleHistory('task', companyId, task.ProjectID, task._id, historyObj, userData)
211+
.catch((error) => {
212+
logger.error(`ERROR in task relation history: ${error.message}`);
213+
});
214+
},
215+
216+
removeRelationHistory({ companyId, task, otherKey, userData }) {
217+
const historyObj = {
218+
key: 'Task_Relation',
219+
message: `<b>${userData?.Employee_Name || 'Someone'}</b> has removed the link with <b>${otherKey}</b>.`,
220+
sprintId: task.sprintId,
221+
};
222+
HandleHistory('task', companyId, task.ProjectID, task._id, historyObj, userData)
223+
.catch((error) => {
224+
logger.error(`ERROR in task relation history: ${error.message}`);
225+
});
226+
},
227+
};

Modules/Tasks/helpers/task_class_Mongo.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ const structuralMixin = require('./taskMongo/structural');
1111
const mergeDuplicateMixin = require('./taskMongo/mergeDuplicate');
1212
const internalsMixin = require('./taskMongo/internals');
1313
const bulkMixin = require('./taskMongo/bulk');
14+
const relationsMixin = require('./taskMongo/relations');
1415

1516
class Task {}
1617

@@ -24,6 +25,7 @@ Object.assign(
2425
mergeDuplicateMixin,
2526
internalsMixin,
2627
bulkMixin,
28+
relationsMixin,
2729
);
2830

2931
exports.taskMongo = new Task();

Modules/Tasks/routes.js

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,38 @@ exports.init = (app) => {
9696
}
9797
});
9898

99+
// Task-to-task relations (blocks / blocked_by / duplicates / duplicated_by
100+
// / relates_to). Single endpoint, explicit action allowlist — same dispatch
101+
// + verified-header companyId convention as POST /api/v2/tasks/bulk above.
102+
// Body: { action: 'add' | 'remove' | 'list', taskId, relatedTaskId?, type?, userData? }
103+
app.post('/api/v2/tasks/relations', (req, res) => {
104+
try {
105+
const RELATION_ACTIONS = {
106+
add: 'addTaskRelation',
107+
remove: 'removeTaskRelation',
108+
list: 'getTaskRelations',
109+
};
110+
const method = RELATION_ACTIONS[req.body && req.body.action];
111+
if (!method) {
112+
return res.send({ status: false, statusText: 'Invalid relation action' });
113+
}
114+
const headerCompanyId = req.headers['companyid'] || '';
115+
const payload = { ...req.body, companyId: headerCompanyId };
116+
117+
taskMongo[method](payload)
118+
.then((response) => {
119+
res.send(response);
120+
})
121+
.catch((error) => {
122+
logger.error(`ERROR relation ${req.body.action}: ${error.message}`);
123+
res.send({ status: false, statusText: error.message });
124+
});
125+
} catch (error) {
126+
logger.error(`ERROR relation dispatch: ${error.message}`);
127+
res.send({ status: false, statusText: error.message });
128+
}
129+
});
130+
99131
app.patch('/api/v1/importTasks', (req, res) => {
100132
taskMongo.createMultipleTasks(req.body)
101133
.then((response) => {

frontend/src/components/molecules/TaskDetailTab/TaskDetailTab.vue

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,10 @@
3636
:subTasksArray="subTasksArray"
3737
:isMainSpinner="isMainSpinner"
3838
/>
39+
<LinkedTasks
40+
:task="task"
41+
class="mt-1"
42+
/>
3943
<div class="position-re">
4044
<div v-if="checkPermission('task.task_custom_field',projectData?.isGlobalPermission) !== null && checkApps('CustomFields')">
4145
<div :class="[{'pointer-event-none opacity-5 blur-3-px':!currentCompany?.planFeature?.customFields}]">
@@ -109,6 +113,7 @@ import Description from '@/components/atom/Description/Description.vue'
109113
import Attachments from '@/components/atom/Attachments/Attachments.vue'
110114
import CheckListComponent from '@/components/molecules/CheckList/CheckList.vue'
111115
import SubTasks from '@/components/organisms/SubTasks/SubTasks.vue'
116+
import LinkedTasks from '@/components/organisms/LinkedTasks/LinkedTasks.vue'
112117
import CreateTagPopup from "@/components/molecules/TagList/CreateTagPopup.vue";
113118
import TagChip from '@/components/atom/TagChip/TagChip.vue'
114119
import PromptSidebar from "@/components/molecules/PromptSidebar/PromptSidebar.vue";

0 commit comments

Comments
 (0)