Skip to content

Commit ae1caaa

Browse files
authored
Merge pull request #256 from aliansoftwareteam/feat/sprint5-implementation
feat(sprint5): gantt/timeline view + recurring tasks
2 parents 4d0289b + 6fd95b6 commit ae1caaa

22 files changed

Lines changed: 1162 additions & 4 deletions

File tree

Config/collections.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ const dbCollections = {
7070
PUBLIC_SHARES: "publicShares",
7171
INTAKE_ITEMS: "intakeItems",
7272
PUBLIC_SHARE_INDEX: "publicShareIndex",
73+
RECURRING_TASKS: "recurring_tasks",
7374
}
7475

7576
/** DOCUMENT ID'S NAME WHICH IS USED IN THE "SETTINGS" COLLECTION NAME **/

Config/schemaType.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ const SCHEMA_TYPE = {
6969
PUBLIC_SHARES: "publicShares",
7070
INTAKE_ITEMS: "intakeItems",
7171
PUBLIC_SHARE_INDEX: "publicShareIndex",
72+
RECURRING_TASKS: "recurring_tasks",
7273
}
7374

7475
module.exports = {
Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
// Recurring tasks — HTTP handlers. CRUD on definitions + a manual run-now /
2+
// run-due (the scheduled job in cron.js runs production-only, so these let the
3+
// feature be exercised in dev). Definitions and instances are company-scoped.
4+
const mongoose = require('mongoose');
5+
const helper = require('./helper');
6+
const { SCHEMA_TYPE } = require('../../Config/schemaType');
7+
const { MongoDbCrudOpration } = require('../../utils/mongo-handler/mongoQueries');
8+
const logger = require('../../Config/loggerConfig');
9+
10+
// Build a valid task `data` template from the request (mirrors the defaults in
11+
// taskMongo.createSubTaskWithAi so taskMongo.create accepts it).
12+
function buildTemplateFromBody(body) {
13+
const project = body.projectData || {};
14+
return {
15+
TaskName: body.taskName,
16+
TaskKey: '-',
17+
AssigneeUserId: Array.isArray(body.assignees) ? body.assignees : [],
18+
watchers: [],
19+
DueDate: '',
20+
dueDateDeadLine: [],
21+
TaskType: body.taskType || 'task',
22+
TaskTypeKey: Number(body.taskTypeKey) || 1,
23+
ParentTaskId: '',
24+
ProjectID: project._id,
25+
CompanyId: project.CompanyId,
26+
status: { text: 'To Do', key: 1, type: 'default_active' },
27+
isParentTask: true,
28+
Task_Leader: (body.userData && body.userData.id) || '',
29+
Task_Priority: body.priority || 'MEDIUM',
30+
deletedStatusKey: 0,
31+
statusType: 'default_active',
32+
statusKey: 1,
33+
points: (body.points === undefined || body.points === null || body.points === '') ? null : Number(body.points),
34+
rawDescription: body.rawDescription || '',
35+
descriptionBlock: body.descriptionBlock || {},
36+
};
37+
}
38+
39+
exports.createDefinition = async (req, res) => {
40+
try {
41+
const companyId = req.headers['companyid'];
42+
const b = req.body || {};
43+
if (!companyId || !b.name || !b.taskName || !b.projectData || !b.projectData._id || !b.freq) {
44+
return res.send({ status: false, statusText: 'Missing required fields (name, taskName, projectData, freq)' });
45+
}
46+
const def = {
47+
_id: new mongoose.Types.ObjectId(),
48+
name: b.name,
49+
ProjectID: new mongoose.Types.ObjectId(b.projectData._id),
50+
sprintId: b.sprintId || (b.sprintArray && (b.sprintArray.id || b.sprintArray._id)) || '',
51+
enabled: true,
52+
freq: b.freq,
53+
interval: Math.max(1, Number(b.interval) || 1),
54+
byweekday: Array.isArray(b.byweekday) ? b.byweekday.map(Number) : [],
55+
monthday: b.monthday ? Number(b.monthday) : undefined,
56+
runHour: Number.isFinite(Number(b.runHour)) ? Number(b.runHour) : 9,
57+
skipIfOpen: !!b.skipIfOpen,
58+
until: b.until ? new Date(b.until) : undefined,
59+
runCount: 0,
60+
templateSnapshot: buildTemplateFromBody(b),
61+
projectSnapshot: {
62+
_id: b.projectData._id,
63+
CompanyId: b.projectData.CompanyId,
64+
ProjectCode: b.projectData.ProjectCode,
65+
ProjectName: b.projectData.ProjectName,
66+
},
67+
userSnapshot: {
68+
id: b.userData && b.userData.id,
69+
Employee_Name: b.userData && b.userData.Employee_Name,
70+
companyOwnerId: b.userData && b.userData.companyOwnerId,
71+
},
72+
sprintArray: b.sprintArray || {},
73+
createdBy: (b.userData && b.userData.id) || '',
74+
deletedStatusKey: 0,
75+
};
76+
def.nextRunAt = helper.computeNextRun(def, new Date());
77+
const saved = await MongoDbCrudOpration(companyId, { type: SCHEMA_TYPE.RECURRING_TASKS, data: def }, 'save');
78+
res.send({ status: true, statusText: 'Recurring task created', data: saved });
79+
} catch (error) {
80+
logger.error(`[recurringTasks] create failed: ${error.message}`);
81+
res.send({ status: false, statusText: error.message });
82+
}
83+
};
84+
85+
exports.listByProject = async (req, res) => {
86+
try {
87+
const companyId = req.headers['companyid'];
88+
const projectId = req.params.pid;
89+
const defs = await MongoDbCrudOpration(companyId, {
90+
type: SCHEMA_TYPE.RECURRING_TASKS,
91+
data: [{ ProjectID: new mongoose.Types.ObjectId(projectId), deletedStatusKey: 0 }],
92+
}, 'find');
93+
res.send({ status: true, data: defs || [] });
94+
} catch (error) {
95+
logger.error(`[recurringTasks] list failed: ${error.message}`);
96+
res.send({ status: false, statusText: error.message });
97+
}
98+
};
99+
100+
exports.updateDefinition = async (req, res) => {
101+
try {
102+
const companyId = req.headers['companyid'];
103+
const id = req.params.id;
104+
const b = req.body || {};
105+
const patch = {};
106+
['name', 'enabled', 'freq', 'interval', 'byweekday', 'monthday', 'runHour', 'skipIfOpen'].forEach((k) => {
107+
if (b[k] !== undefined) patch[k] = b[k];
108+
});
109+
if (b.until !== undefined) patch.until = b.until ? new Date(b.until) : null;
110+
111+
// If the schedule changed, recompute nextRunAt from the merged definition.
112+
const scheduleChanged = ['freq', 'interval', 'byweekday', 'monthday', 'runHour'].some((k) => b[k] !== undefined);
113+
if (scheduleChanged) {
114+
const existing = await MongoDbCrudOpration(companyId, {
115+
type: SCHEMA_TYPE.RECURRING_TASKS,
116+
data: [{ _id: new mongoose.Types.ObjectId(id) }],
117+
}, 'findOne');
118+
if (existing) {
119+
patch.nextRunAt = helper.computeNextRun(Object.assign({}, existing.toObject ? existing.toObject() : existing, patch), new Date());
120+
}
121+
}
122+
await helper.updateDef(companyId, id, patch);
123+
res.send({ status: true, statusText: 'Updated' });
124+
} catch (error) {
125+
logger.error(`[recurringTasks] update failed: ${error.message}`);
126+
res.send({ status: false, statusText: error.message });
127+
}
128+
};
129+
130+
exports.deleteDefinition = async (req, res) => {
131+
try {
132+
const companyId = req.headers['companyid'];
133+
const id = req.params.id;
134+
await helper.updateDef(companyId, id, { deletedStatusKey: 1, enabled: false });
135+
res.send({ status: true, statusText: 'Deleted' });
136+
} catch (error) {
137+
logger.error(`[recurringTasks] delete failed: ${error.message}`);
138+
res.send({ status: false, statusText: error.message });
139+
}
140+
};
141+
142+
// Instantiate one task right now from a definition (testing / manual trigger).
143+
exports.runNow = async (req, res) => {
144+
try {
145+
const companyId = req.headers['companyid'];
146+
const id = req.params.id;
147+
const def = await MongoDbCrudOpration(companyId, {
148+
type: SCHEMA_TYPE.RECURRING_TASKS,
149+
data: [{ _id: new mongoose.Types.ObjectId(id) }],
150+
}, 'findOne');
151+
if (!def) return res.send({ status: false, statusText: 'Definition not found' });
152+
const out = await helper.instantiateOne(companyId, def);
153+
const patch = { lastRunAt: new Date(), runCount: (Number(def.runCount) || 0) + (out.created ? 1 : 0) };
154+
if (out.id) patch.lastInstanceTaskId = String(out.id);
155+
await helper.updateDef(companyId, id, patch);
156+
res.send({
157+
status: !!(out.created || out.skipped),
158+
statusText: out.skipped ? 'Skipped — previous instance still open' : 'Task created',
159+
data: { id: out.id || null, skipped: !!out.skipped },
160+
});
161+
} catch (error) {
162+
logger.error(`[recurringTasks] runNow failed: ${error.message}`);
163+
res.send({ status: false, statusText: error.message });
164+
}
165+
};
166+
167+
// Process all due definitions for the caller's company (manual trigger; the
168+
// cron does this for every company in production).
169+
exports.runDueForCompany = async (req, res) => {
170+
try {
171+
const companyId = req.headers['companyid'];
172+
const result = await helper.processDueForCompany(companyId);
173+
res.send({ status: true, statusText: 'Processed due recurring tasks', data: result });
174+
} catch (error) {
175+
logger.error(`[recurringTasks] runDue failed: ${error.message}`);
176+
res.send({ status: false, statusText: error.message });
177+
}
178+
};
179+
180+
// Cron entry (all companies) — consumed by cron.js.
181+
exports.runRecurringForAllCompanies = helper.runRecurringForAllCompanies;

Modules/RecurringTasks/helper.js

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
// Recurring task definitions — pure logic: schedule math + instantiation.
2+
// HTTP handlers live in controller.js; cron entry is runRecurringForAllCompanies().
3+
const mongoose = require('mongoose');
4+
const { SCHEMA_TYPE } = require('../../Config/schemaType');
5+
const { MongoDbCrudOpration } = require('../../utils/mongo-handler/mongoQueries');
6+
const { taskMongo } = require('../Tasks/helpers/task_class_Mongo');
7+
const logger = require('../../Config/loggerConfig');
8+
9+
const COMPANY_CONCURRENCY = 5;
10+
const LOG_PREFIX = '[recurringTasks]';
11+
12+
function atHour(year, month, day, hour) {
13+
return new Date(year, month, day, hour, 0, 0, 0);
14+
}
15+
16+
// The next run strictly after `fromDate`, per the definition's schedule.
17+
function computeNextRun(def, fromDate) {
18+
const from = fromDate ? new Date(fromDate) : new Date();
19+
const hour = Number.isFinite(Number(def.runHour)) ? Number(def.runHour) : 9;
20+
const interval = Math.max(1, Number(def.interval) || 1);
21+
22+
if (def.freq === 'weekly') {
23+
const days = (Array.isArray(def.byweekday) && def.byweekday.length) ? def.byweekday.map(Number) : [from.getDay()];
24+
for (let i = 1; i <= 7 * interval + 7; i++) {
25+
const c = atHour(from.getFullYear(), from.getMonth(), from.getDate() + i, hour);
26+
if (days.includes(c.getDay())) return c;
27+
}
28+
return atHour(from.getFullYear(), from.getMonth(), from.getDate() + 7, hour);
29+
}
30+
if (def.freq === 'monthly') {
31+
// cap at 28 so we never overflow into the next month on short months
32+
const dom = Math.min(28, Math.max(1, Number(def.monthday) || from.getDate()));
33+
let c = atHour(from.getFullYear(), from.getMonth(), dom, hour);
34+
while (c <= from) {
35+
c = atHour(c.getFullYear(), c.getMonth() + interval, dom, hour);
36+
}
37+
return c;
38+
}
39+
// daily (default)
40+
return atHour(from.getFullYear(), from.getMonth(), from.getDate() + interval, hour);
41+
}
42+
43+
// Clone the stored template into a fresh task `data` payload for taskMongo.create.
44+
function buildInstanceData(def, companyId) {
45+
const t = Object.assign({}, def.templateSnapshot || {});
46+
const projectId = (def.projectSnapshot && def.projectSnapshot._id) || def.ProjectID;
47+
return Object.assign(t, {
48+
_id: new mongoose.Types.ObjectId(),
49+
TaskKey: '-',
50+
ProjectID: projectId,
51+
CompanyId: companyId,
52+
sprintId: def.sprintId,
53+
sprintArray: def.sprintArray || t.sprintArray,
54+
deletedStatusKey: 0,
55+
startDate: new Date(),
56+
});
57+
}
58+
59+
async function isPreviousInstanceOpen(companyId, taskId) {
60+
if (!taskId) return false;
61+
try {
62+
const res = await MongoDbCrudOpration(companyId, {
63+
type: SCHEMA_TYPE.TASKS,
64+
data: [{ _id: new mongoose.Types.ObjectId(taskId) }, 'statusType deletedStatusKey'],
65+
}, 'findOne');
66+
if (!res) return false;
67+
return res.deletedStatusKey !== 1 && res.statusType !== 'close';
68+
} catch (e) {
69+
return false;
70+
}
71+
}
72+
73+
// Create one task instance from a definition. Returns { created, id, skipped }.
74+
async function instantiateOne(companyId, def) {
75+
if (def.skipIfOpen && await isPreviousInstanceOpen(companyId, def.lastInstanceTaskId)) {
76+
return { created: false, skipped: true };
77+
}
78+
const data = buildInstanceData(def, companyId);
79+
const indexObj = { indexName: 'groupByStatusIndex', searchKey: 'statusKey', searchValue: String(data.statusKey || 1) };
80+
const result = await taskMongo.create({
81+
data,
82+
user: def.userSnapshot || { id: def.createdBy, Employee_Name: '', companyOwnerId: '' },
83+
projectData: def.projectSnapshot || { _id: data.ProjectID, CompanyId: companyId },
84+
indexObj,
85+
});
86+
return { created: !!(result && result.status), id: result && result.id, skipped: false };
87+
}
88+
89+
// updateOne $set on a definition.
90+
async function updateDef(companyId, id, patch) {
91+
return MongoDbCrudOpration(companyId, {
92+
type: SCHEMA_TYPE.RECURRING_TASKS,
93+
data: [{ _id: new mongoose.Types.ObjectId(id) }, { $set: patch }],
94+
}, 'updateOne');
95+
}
96+
97+
// Process every due, enabled definition for one company.
98+
async function processDueForCompany(companyId, now) {
99+
const ref = now ? new Date(now) : new Date();
100+
let defs = [];
101+
try {
102+
defs = await MongoDbCrudOpration(companyId, {
103+
type: SCHEMA_TYPE.RECURRING_TASKS,
104+
data: [{ enabled: true, deletedStatusKey: 0, nextRunAt: { $lte: ref } }],
105+
}, 'find');
106+
} catch (e) {
107+
logger.error(`${LOG_PREFIX} find-due failed for ${companyId}: ${e.message}`);
108+
return { processed: 0, created: 0 };
109+
}
110+
let created = 0;
111+
for (const def of (defs || [])) {
112+
try {
113+
if (def.until && new Date(def.until) < ref) {
114+
await updateDef(companyId, def._id, { enabled: false });
115+
continue;
116+
}
117+
const out = await instantiateOne(companyId, def);
118+
if (out.created) created++;
119+
const next = computeNextRun(def, ref);
120+
const patch = {
121+
lastRunAt: ref,
122+
runCount: (Number(def.runCount) || 0) + (out.created ? 1 : 0),
123+
nextRunAt: next,
124+
};
125+
if (out.id) patch.lastInstanceTaskId = String(out.id);
126+
if (def.until && next > new Date(def.until)) patch.enabled = false;
127+
await updateDef(companyId, def._id, patch);
128+
} catch (e) {
129+
logger.error(`${LOG_PREFIX} instantiate failed (${companyId}/${def._id}): ${e.message}`);
130+
}
131+
}
132+
return { processed: (defs || []).length, created };
133+
}
134+
135+
// Cron entry: scan every company for due definitions (bounded concurrency).
136+
async function runRecurringForAllCompanies() {
137+
let companies = [];
138+
try {
139+
companies = await MongoDbCrudOpration(SCHEMA_TYPE.GOLBAL, {
140+
type: SCHEMA_TYPE.COMPANIES,
141+
data: [{}, '_id'],
142+
}, 'find');
143+
} catch (e) {
144+
logger.error(`${LOG_PREFIX} could not enumerate companies: ${e.message}`);
145+
return;
146+
}
147+
for (let i = 0; i < (companies || []).length; i += COMPANY_CONCURRENCY) {
148+
const slice = companies.slice(i, i + COMPANY_CONCURRENCY);
149+
await Promise.allSettled(slice.map((c) => processDueForCompany(String(c._id))));
150+
}
151+
logger.info(`${LOG_PREFIX} run complete across ${(companies || []).length} companies`);
152+
}
153+
154+
module.exports = {
155+
computeNextRun,
156+
buildInstanceData,
157+
instantiateOne,
158+
processDueForCompany,
159+
updateDef,
160+
runRecurringForAllCompanies,
161+
};

Modules/RecurringTasks/init.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
const routes = require('./routes');
2+
3+
exports.init = (app) => routes.init(app);

Modules/RecurringTasks/routes.js

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
const controller = require('./controller');
2+
const logger = require('../../Config/loggerConfig');
3+
4+
exports.init = (app) => {
5+
// Create a recurring task definition for a project.
6+
app.post('/api/v1/recurring-tasks', controller.createDefinition);
7+
// List a project's recurring definitions.
8+
app.get('/api/v1/recurring-tasks/project/:pid', controller.listByProject);
9+
// Edit / pause / resume (PATCH { enabled, freq, ... }).
10+
app.patch('/api/v1/recurring-tasks/:id', controller.updateDefinition);
11+
// Soft-delete a definition.
12+
app.delete('/api/v1/recurring-tasks/:id', controller.deleteDefinition);
13+
// Instantiate one task immediately (manual / testing).
14+
app.post('/api/v1/recurring-tasks/:id/run-now', controller.runNow);
15+
// Process every due definition for the caller's company (manual; cron does this in prod).
16+
app.post('/api/v1/recurring-tasks/run-due', controller.runDueForCompany);
17+
logger.info('RecurringTasks routes initialised');
18+
};

0 commit comments

Comments
 (0)