Skip to content

Commit b06ee2a

Browse files
authored
Merge pull request #259 from aliansoftwareteam/feat/relations-epics-enhancements
feat(tasks,epics): blocked-task warning + epic dates/owner/priority/progress
2 parents 42a80b3 + 4e47b64 commit b06ee2a

13 files changed

Lines changed: 429 additions & 17 deletions

File tree

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# Blocked-Task Warning — Test Cases
2+
3+
**Feature:** A task that is `blocked_by` one or more still-open tasks shows a clear "⚠ Blocked by N open task(s)" banner in its Linked Tasks panel. Makes AlianHub's blocked-task alert real.
4+
5+
**Location:**
6+
- Frontend: Task detail → **Linked Tasks** panel — `frontend/src/components/organisms/LinkedTasks/LinkedTasks.vue` (computed `openBlockers`, banner).
7+
- Backend: `POST /api/v2/tasks/relations` action `openBlockers``getOpenBlockers` (`Modules/Tasks/helpers/taskMongo/relations.js`); pure helper `selectOpenBlockers` (`relationRules.js`). A blocker counts only while `statusType !== 'close'` and not soft-deleted.
8+
9+
**Legend:** ✅ Pass · ❌ Fail · ⏳ Pending (not yet run in the app)
10+
11+
| ID | Title | Precondition | Steps | Expected | Actual | Status |
12+
|----|-------|--------------|-------|----------|--------|--------|
13+
| BW_001 | Warning shows for an open blocker | Task B is `blocked_by` Task A; A is open | Open Task B → Linked Tasks panel | Amber banner: "⚠ Blocked by 1 open task(s): {A.key}" | | ⏳ Pending |
14+
| BW_002 | Warning clears when the blocker closes | As BW_001 | Move Task A to a Done/closed status; reopen Task B | Banner disappears (0 open blockers) | | ⏳ Pending |
15+
| BW_003 | Only `blocked_by` counts | Task B `blocks` Task A (B blocks, isn't blocked) | Open Task B | No banner on B | | ⏳ Pending |
16+
| BW_004 | Multiple open blockers listed | B `blocked_by` A and C, both open | Open Task B | Banner shows count 2 and both keys | | ⏳ Pending |
17+
| BW_005 | Soft-deleted blocker ignored | B `blocked_by` A; A soft-deleted | Open Task B | A not counted in the banner | | ⏳ Pending |
18+
| BW_006 | `openBlockers` API action | B `blocked_by` A (open) | POST /api/v2/tasks/relations {action:'openBlockers', taskId:B} | `{ status:true, data:[A summary] }` | | ⏳ Pending |
19+
| BW_007 | duplicates / relates_to never block | B `relates_to`/`duplicates` A | Open Task B | No banner | | ⏳ Pending |
20+
21+
**Unit coverage:** `selectOpenBlockers` + `isClosedStatusType` fully covered in `tests/task-relations-rules.test.js` (open / closed / deleted / wrong-type / null / mixed).
22+
23+
**Total:** 7 manual cases · pure logic unit-tested (green).
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
# Epic Enhancements (dates / owner / priority / status / progress) — Test Cases
2+
3+
**Feature:** Epics gain **start/due dates**, an **owner**, a **priority** (low/medium/high), an **in_progress** status (alongside open/done), and a visible **progress bar with %** — turning the thin v1 into a usable progress layer.
4+
5+
**Location:**
6+
- Frontend: **Epics** panel — `frontend/src/components/molecules/Epics/EpicsPanel.vue` (create form fields, priority badge, status select, owner + dates, % label).
7+
- Backend: `Modules/Epics/controller.js` (create/update accept the new fields), `helpers/epicRules.js` (`EPIC_PRIORITIES`, `parseEpicDates`, extended `EPIC_STATUSES`), schema `utils/mongo-handler/schema.js` (epics: priority, ownerUserId, startDate, dueDate).
8+
9+
**Legend:** ✅ Pass · ❌ Fail · ⏳ Pending (not yet run in the app)
10+
11+
| ID | Title | Precondition | Steps | Expected | Actual | Status |
12+
|----|-------|--------------|-------|----------|--------|--------|
13+
| EE_001 | Create epic with priority + dates | Project open; Epics panel open | Enter name, pick priority **High**, set start + due, click Add | Epic created; owner defaults to creator | | ⏳ Pending |
14+
| EE_002 | Row renders new metadata | EE_001 done | View the epic row | Priority badge (High), owner name, due date all shown | | ⏳ Pending |
15+
| EE_003 | Progress bar + % label | Epic has 4 tasks, 1 closed | View epic row | Bar ≈25%, label "25% · 1/4" | | ⏳ Pending |
16+
| EE_004 | Set status In progress | Epic exists | Pick "In progress" in the row status select | Persists (PUT /epics/:id); chip tinted amber | | ⏳ Pending |
17+
| EE_005 | Set status Done | Epic exists | Pick "Done" | Persists; chip tinted green | | ⏳ Pending |
18+
| EE_006 | Invalid priority rejected (API) || PUT /api/v2/epics/:id {priority:'urgent'} | `{ status:false, "Invalid priority." }` | | ⏳ Pending |
19+
| EE_007 | start-after-due rejected (API) || POST /api/v2/epics {startDate:'2026-07-01', dueDate:'2026-06-01', …} | `{ status:false, "startDate must be on or before dueDate." }` | | ⏳ Pending |
20+
| EE_008 | Owner shows Unassigned when empty | Epic with no ownerUserId | View the row | Owner: "Unassigned" | | ⏳ Pending |
21+
22+
**Unit coverage:** `validateEpicInput` (priority), `parseEpicDates` (valid / clear / partial / unparseable / start>due), and `EPIC_STATUSES`/`EPIC_PRIORITIES` covered in `tests/epic-rules.test.js`.
23+
24+
**Total:** 8 manual cases · pure logic unit-tested (green).

Modules/Epics/controller.js

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ const { MongoDbCrudOpration } = require("../../utils/mongo-handler/mongoQueries"
33
const mongoose = require("mongoose");
44
const logger = require("../../Config/loggerConfig");
55
const socketEmitter = require('../../event/socketEventEmitter');
6-
const { validateEpicInput, validateAssignInput, countDeltas, isObjectIdString, EPIC_STATUSES } = require('./helpers/epicRules');
6+
const { validateEpicInput, validateAssignInput, countDeltas, isObjectIdString, EPIC_STATUSES, EPIC_PRIORITIES, parseEpicDates } = require('./helpers/epicRules');
77

88
// Epics: a grouping layer above tasks with progress roll-up. Tasks carry an
99
// optional epicId; epics keep denormalised taskCount/completedCount which
@@ -13,11 +13,15 @@ const { validateEpicInput, validateAssignInput, countDeltas, isObjectIdString, E
1313
exports.createEpic = async (req, res) => {
1414
try {
1515
const companyId = req.headers['companyid'] || '';
16-
const { name, description, color, projectId, userData } = req.body || {};
17-
const check = validateEpicInput({ companyId, name, projectId, color });
16+
const { name, description, color, projectId, priority, ownerUserId, startDate, dueDate, userData } = req.body || {};
17+
const check = validateEpicInput({ companyId, name, projectId, color, priority });
1818
if (!check.valid) {
1919
return res.send({ status: false, statusText: check.reason });
2020
}
21+
const dateCheck = parseEpicDates({ startDate, dueDate });
22+
if (!dateCheck.valid) {
23+
return res.send({ status: false, statusText: dateCheck.reason });
24+
}
2125
const created = await MongoDbCrudOpration(companyId, {
2226
type: SCHEMA_TYPE.EPICS,
2327
data: {
@@ -26,6 +30,10 @@ exports.createEpic = async (req, res) => {
2630
ProjectID: new mongoose.Types.ObjectId(projectId),
2731
color: color || '#7b68ee',
2832
status: 'open',
33+
priority: priority ? String(priority) : 'medium',
34+
ownerUserId: ownerUserId ? String(ownerUserId) : '',
35+
startDate: dateCheck.startDate || null,
36+
dueDate: dateCheck.dueDate || null,
2937
createdBy: userData && (userData.id || userData._id) ? String(userData.id || userData._id) : '',
3038
taskCount: 0,
3139
completedCount: 0,
@@ -70,7 +78,7 @@ exports.updateEpic = async (req, res) => {
7078
if (!companyId || !isObjectIdString(id)) {
7179
return res.send({ status: false, statusText: 'companyId and a valid epic id are required.' });
7280
}
73-
const { name, color, status, description } = req.body || {};
81+
const { name, color, status, description, priority, ownerUserId, startDate, dueDate } = req.body || {};
7482
const update = {};
7583
if (name !== undefined) {
7684
if (!String(name).trim()) return res.send({ status: false, statusText: 'name cannot be empty.' });
@@ -82,6 +90,19 @@ exports.updateEpic = async (req, res) => {
8290
if (!EPIC_STATUSES.includes(status)) return res.send({ status: false, statusText: 'Invalid status.' });
8391
update.status = status;
8492
}
93+
if (priority !== undefined) {
94+
if (priority !== '' && priority !== null && !EPIC_PRIORITIES.includes(String(priority))) {
95+
return res.send({ status: false, statusText: 'Invalid priority.' });
96+
}
97+
update.priority = priority ? String(priority) : '';
98+
}
99+
if (ownerUserId !== undefined) update.ownerUserId = ownerUserId ? String(ownerUserId) : '';
100+
if (startDate !== undefined || dueDate !== undefined) {
101+
const dateCheck = parseEpicDates({ startDate, dueDate });
102+
if (!dateCheck.valid) return res.send({ status: false, statusText: dateCheck.reason });
103+
if (startDate !== undefined) update.startDate = dateCheck.startDate || null;
104+
if (dueDate !== undefined) update.dueDate = dateCheck.dueDate || null;
105+
}
85106
if (!Object.keys(update).length) {
86107
return res.send({ status: false, statusText: 'Nothing to update.' });
87108
}

Modules/Epics/helpers/epicRules.js

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,13 @@
33
const OBJECT_ID_PATTERN = /^[0-9a-fA-F]{24}$/;
44
const HEX_COLOR_PATTERN = /^#[0-9a-fA-F]{6}$/;
55
const MAX_NAME_LENGTH = 120;
6-
const EPIC_STATUSES = Object.freeze(['open', 'done']);
6+
const EPIC_STATUSES = Object.freeze(['open', 'in_progress', 'done']);
7+
const EPIC_PRIORITIES = Object.freeze(['low', 'medium', 'high']);
78

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

1011
/* Validate epic creation/update input. Returns { valid, reason }. */
11-
const validateEpicInput = ({ companyId, name, projectId, color }) => {
12+
const validateEpicInput = ({ companyId, name, projectId, color, priority }) => {
1213
if (!companyId) {
1314
return { valid: false, reason: 'companyId is required.' };
1415
}
@@ -21,6 +22,9 @@ const validateEpicInput = ({ companyId, name, projectId, color }) => {
2122
if (color !== undefined && color !== '' && !HEX_COLOR_PATTERN.test(String(color))) {
2223
return { valid: false, reason: 'color must be a #rrggbb hex value.' };
2324
}
25+
if (priority !== undefined && priority !== '' && priority !== null && !EPIC_PRIORITIES.includes(String(priority))) {
26+
return { valid: false, reason: `priority must be one of: ${EPIC_PRIORITIES.join(', ')}.` };
27+
}
2428
return { valid: true, reason: '' };
2529
};
2630

@@ -56,11 +60,39 @@ const countDeltas = ({ oldEpicId, newEpicId, isCompleted }) => {
5660
return deltas;
5761
};
5862

63+
/* Parse + validate optional epic dates. Returns { valid, reason, startDate,
64+
* dueDate } with Date objects (or null). Rejects unparseable dates, and a
65+
* startDate later than dueDate when both are supplied in the same call. */
66+
const parseEpicDates = ({ startDate, dueDate }) => {
67+
const toDate = (v) => {
68+
if (v === null || v === '') return null;
69+
const d = new Date(v);
70+
return Number.isNaN(d.getTime()) ? false : d;
71+
};
72+
const out = { valid: true, reason: '', startDate: undefined, dueDate: undefined };
73+
if (startDate !== undefined) {
74+
const d = toDate(startDate);
75+
if (d === false) return { valid: false, reason: 'startDate is not a valid date.' };
76+
out.startDate = d;
77+
}
78+
if (dueDate !== undefined) {
79+
const d = toDate(dueDate);
80+
if (d === false) return { valid: false, reason: 'dueDate is not a valid date.' };
81+
out.dueDate = d;
82+
}
83+
if (out.startDate && out.dueDate && out.startDate.getTime() > out.dueDate.getTime()) {
84+
return { valid: false, reason: 'startDate must be on or before dueDate.' };
85+
}
86+
return out;
87+
};
88+
5989
module.exports = {
6090
EPIC_STATUSES,
91+
EPIC_PRIORITIES,
6192
MAX_NAME_LENGTH,
6293
isObjectIdString,
6394
validateEpicInput,
6495
validateAssignInput,
96+
parseEpicDates,
6597
countDeltas,
6698
};

Modules/Tasks/helpers/taskMongo/relationRules.js

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,12 +75,31 @@ const validateRelationInput = ({ companyId, taskId, relatedTaskId, type }) => {
7575
return { valid: true, reason: '' };
7676
};
7777

78+
// A task is complete when its statusType is 'close' — the same signal the epic
79+
// counters and /recount use. A blocker only "blocks" while it is still open.
80+
const isClosedStatusType = (statusType) => String(statusType || '') === 'close';
81+
82+
/* From a task's relation-list entries (each `{ type, task }`, as returned by
83+
* getTaskRelations), pick the OPEN blockers: `blocked_by` links whose blocking
84+
* task still exists, isn't soft-deleted, and isn't closed. Pure — shared by the
85+
* controller and the unit tests so "what counts as blocked" lives in one place. */
86+
const selectOpenBlockers = (relationItems) =>
87+
(relationItems || []).filter((item) =>
88+
item &&
89+
item.type === RELATION_TYPES.BLOCKED_BY &&
90+
item.task &&
91+
item.task.deletedStatusKey !== 1 &&
92+
!isClosedStatusType(item.task.statusType)
93+
);
94+
7895
module.exports = {
7996
RELATION_TYPES,
8097
RELATION_TYPE_LIST,
8198
INVERSE_RELATION,
8299
RELATION_LABELS,
83100
isObjectIdString,
101+
isClosedStatusType,
102+
selectOpenBlockers,
84103
validateTaskRef,
85104
validateRelationPair,
86105
validateRelationInput,

Modules/Tasks/helpers/taskMongo/relations.js

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ const { default: mongoose } = require("mongoose");
55
const socketEmitter = require('../../../../event/socketEventEmitter');
66
const { HandleHistory } = require("../mongo_helper");
77
const { HandleBothNotification } = require("../handleNotification");
8-
const { INVERSE_RELATION, RELATION_LABELS, validateTaskRef, validateRelationPair, validateRelationInput } = require('./relationRules');
8+
const { INVERSE_RELATION, RELATION_LABELS, validateTaskRef, validateRelationPair, validateRelationInput, selectOpenBlockers } = require('./relationRules');
99

1010
// Task-to-task relations (blocks / blocked_by / duplicates / duplicated_by /
1111
// relates_to). A link is stored on BOTH task documents as an entry in the
@@ -176,6 +176,27 @@ module.exports = {
176176
})
177177
},
178178

179+
/* -------------- LIST OPEN BLOCKERS OF A TASK -----------------*/
180+
// payload: { companyId, taskId }. Returns the `blocked_by` links whose
181+
// blocking task is still open — i.e. why this task isn't unblocked yet.
182+
// Drives the "blocked by N open task(s)" warning shown on the task.
183+
getOpenBlockers({ companyId, taskId }) {
184+
return new Promise(async (resolve, reject) => {
185+
try {
186+
const relationsResult = await this.getTaskRelations({ companyId, taskId });
187+
const openBlockers = selectOpenBlockers(relationsResult.data || []);
188+
resolve({
189+
status: true,
190+
statusText: openBlockers.length ? `${openBlockers.length} open blocker(s).` : 'No open blockers.',
191+
data: openBlockers,
192+
});
193+
} catch (error) {
194+
logger.error(`ERROR in get open blockers: ${error.message}`);
195+
reject(error);
196+
}
197+
})
198+
},
199+
179200
/* -------------- INTERNAL HELPERS (RELATIONS) -----------------*/
180201

181202
findRelationTask(companyId, taskObjId) {

Modules/Tasks/routes.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@ exports.init = (app) => {
107107
add: 'addTaskRelation',
108108
remove: 'removeTaskRelation',
109109
list: 'getTaskRelations',
110+
openBlockers: 'getOpenBlockers',
110111
};
111112
const method = RELATION_ACTIONS[req.body && req.body.action];
112113
if (!method) {

0 commit comments

Comments
 (0)