tasks.js 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543
  1. // Zoho Projects v3 API Module
  2. // EU data center: https://projects.zoho.eu
  3. // Collection endpoints: no trailing slash. Resource endpoints: trailing slash optional.
  4. var API_BASE = 'https://projects.zoho.eu';
  5. function makeRequest(token, method, path, jsonBody, queryParams) {
  6. var portalId = shadowman.config.value('portal_id');
  7. if (!portalId) {
  8. return { error: 'Missing portal_id in plugin config. Run resolve_portal first.' };
  9. }
  10. var url = API_BASE + path.replace('{PORTALID}', portalId);
  11. if (queryParams) {
  12. var qs = [];
  13. for (var key in queryParams) {
  14. if (queryParams.hasOwnProperty(key) && queryParams[key] !== undefined) {
  15. qs.push(encodeURIComponent(key) + '=' + encodeURIComponent(queryParams[key]));
  16. }
  17. }
  18. if (qs.length > 0) {
  19. url += '?' + qs.join('&');
  20. }
  21. }
  22. var hasBody = (jsonBody !== null && jsonBody !== undefined);
  23. var headers = {
  24. 'Authorization': 'Zoho-oauthtoken ' + token
  25. };
  26. if (hasBody) {
  27. headers['Content-Type'] = 'application/json';
  28. }
  29. var resp = shadowman.http.request(url, {
  30. method: method,
  31. body: hasBody ? JSON.stringify(jsonBody) : null,
  32. headers: headers,
  33. contentType: hasBody ? 'application/json' : null
  34. });
  35. if (resp.status === 401) {
  36. return { _tokenExpired: true };
  37. }
  38. if (resp.status >= 400) {
  39. var raw = resp.body;
  40. var msg;
  41. if (typeof raw === 'string') {
  42. try {
  43. var p = JSON.parse(raw);
  44. msg = p.error && p.error.message ? p.error.message : raw;
  45. } catch (e) {
  46. msg = raw;
  47. }
  48. } else if (raw && typeof raw === 'object') {
  49. msg = (raw.error && raw.error.message) || raw.message || raw.description || JSON.stringify(raw);
  50. } else {
  51. msg = String(raw);
  52. }
  53. return { error: 'Zoho API error (' + resp.status + '): ' + msg };
  54. }
  55. if (resp.body && typeof resp.body === 'object') {
  56. return resp.body;
  57. }
  58. if (!resp.body || (typeof resp.body === 'string' && resp.body.trim() === '')) {
  59. return { success: true };
  60. }
  61. try {
  62. return JSON.parse(resp.body);
  63. } catch (e) {
  64. return { error: 'Invalid JSON response: ' + resp.body };
  65. }
  66. }
  67. function apiCall(token, method, path, jsonBody, queryParams) {
  68. var result = makeRequest(token, method, path, jsonBody, queryParams);
  69. if (result && result._tokenExpired) {
  70. var auth = require('./auth');
  71. var newToken = auth.getValidToken();
  72. if (!newToken) {
  73. return { error: 'Authentication expired and refresh failed. Re-run setup_auth.' };
  74. }
  75. return makeRequest(newToken, method, path, jsonBody, queryParams);
  76. }
  77. return result;
  78. }
  79. // --- Projects ---
  80. function listProjects(token, index) {
  81. var portalId = shadowman.config.value('portal_id');
  82. if (!portalId) {
  83. return { error: 'Missing portal_id in plugin config. Run resolve_portal first.' };
  84. }
  85. var params = {};
  86. if (index) params.index = String(index);
  87. return apiCall(token, 'GET', '/api/v3/portal/{PORTALID}/projects', null, params);
  88. }
  89. function getProject(token, projectId) {
  90. return apiCall(token, 'GET',
  91. '/api/v3/portal/{PORTALID}/projects/{PROJECTID}'
  92. .replace('{PROJECTID}', projectId), null, {});
  93. }
  94. // --- Tasks ---
  95. function listTasks(token, projectId, filters) {
  96. var params = {};
  97. if (filters.status) params.status = filters.status;
  98. if (filters.priority) params.priority = filters.priority;
  99. if (filters.owner) params.owner = filters.owner;
  100. if (filters.index) params.index = String(filters.index);
  101. if (filters.milestone_id) params.milestone_id = filters.milestone_id;
  102. if (filters.tasklist_id) params.tasklist_id = filters.tasklist_id;
  103. return apiCall(token, 'GET',
  104. '/api/v3/portal/{PORTALID}/projects/{PROJECTID}/tasks'
  105. .replace('{PROJECTID}', projectId), null, params);
  106. }
  107. function getTask(token, projectId, taskId) {
  108. return apiCall(token, 'GET',
  109. '/api/v3/portal/{PORTALID}/projects/{PROJECTID}/tasks/{TASKID}'
  110. .replace('{PROJECTID}', projectId)
  111. .replace('{TASKID}', taskId), null, {});
  112. }
  113. function createTask(token, projectId, args) {
  114. var body = { name: args.name };
  115. if (args.description) body.description = args.description;
  116. if (args.person_responsible) body.person_responsible = String(args.person_responsible);
  117. if (args.priority) body.priority = args.priority;
  118. if (args.start_date) body.start_date = args.start_date;
  119. if (args.end_date) body.end_date = args.end_date;
  120. if (args.tasklist_id) body.tasklist_id = String(args.tasklist_id);
  121. if (args.milestone_id) body.milestone_id = String(args.milestone_id);
  122. if (args.percent_complete !== undefined) body.completion_percentage = String(args.percent_complete);
  123. // v3 extras
  124. if (args.status_id) body.status = { id: String(args.status_id) };
  125. if (args.parent_task_id) body.parental_info = { parent_task_id: String(args.parent_task_id) };
  126. if (args.billing_type) body.billing_type = args.billing_type;
  127. return apiCall(token, 'POST',
  128. '/api/v3/portal/{PORTALID}/projects/{PROJECTID}/tasks'
  129. .replace('{PROJECTID}', projectId), body);
  130. }
  131. function updateTask(token, projectId, taskId, args) {
  132. var body = {};
  133. if (args.name) body.name = args.name;
  134. if (args.description !== undefined) body.description = args.description;
  135. if (args.person_responsible) body.person_responsible = String(args.person_responsible);
  136. if (args.priority) body.priority = args.priority;
  137. if (args.start_date) body.start_date = args.start_date;
  138. if (args.end_date) body.end_date = args.end_date;
  139. if (args.percent_complete !== undefined) body.completion_percentage = String(args.percent_complete);
  140. if (args.status_id) body.status = { id: String(args.status_id) };
  141. if (args.tasklist_id) body.tasklist = { id: String(args.tasklist_id) };
  142. if (args.milestone_id) body.milestone_id = String(args.milestone_id);
  143. if (args.billing_type) body.billing_type = args.billing_type;
  144. return apiCall(token, 'PATCH',
  145. '/api/v3/portal/{PORTALID}/projects/{PROJECTID}/tasks/{TASKID}'
  146. .replace('{PROJECTID}', projectId)
  147. .replace('{TASKID}', taskId), body);
  148. }
  149. function deleteTask(token, projectId, taskId) {
  150. return apiCall(token, 'DELETE',
  151. '/api/v3/portal/{PORTALID}/projects/{PROJECTID}/tasks/{TASKID}'
  152. .replace('{PROJECTID}', projectId)
  153. .replace('{TASKID}', taskId), null, {});
  154. }
  155. function listSubtasks(token, projectId, taskId) {
  156. return apiCall(token, 'GET',
  157. '/api/v3/portal/{PORTALID}/projects/{PROJECTID}/tasks/{TASKID}/subtasks'
  158. .replace('{PROJECTID}', projectId)
  159. .replace('{TASKID}', taskId), null, {});
  160. }
  161. // --- My Tasks ---
  162. function getMyTasks(token, filters) {
  163. var params = {};
  164. if (filters.status) params.status = filters.status;
  165. if (filters.priority) params.priority = filters.priority;
  166. if (filters.index) params.index = String(filters.index);
  167. return apiCall(token, 'GET', '/api/v3/portal/{PORTALID}/mytasks', null, params);
  168. }
  169. // --- Search ---
  170. function searchTasks(token, args) {
  171. var portalId = shadowman.config.value('portal_id');
  172. if (!portalId) return { error: 'Missing portal_id in plugin config.' };
  173. var query = args.query;
  174. var projectId = args.project_id;
  175. if (projectId) {
  176. var result = apiCall(token, 'GET',
  177. '/api/v3/portal/{PORTALID}/projects/{PROJECTID}/tasks'
  178. .replace('{PROJECTID}', projectId), null, { index: '1' });
  179. if (result.error) return result;
  180. return filterTasks(result.tasks || [], query);
  181. }
  182. var projects = listProjects(token, 1);
  183. if (projects.error) return projects;
  184. var allTasks = [];
  185. var projectList = projects.projects || [];
  186. for (var p = 0; p < projectList.length; p++) {
  187. var proj = projectList[p];
  188. var taskResult = apiCall(token, 'GET',
  189. '/api/v3/portal/{PORTALID}/projects/{PROJECTID}/tasks'
  190. .replace('{PROJECTID}', String(proj.id)), null, { index: '1' });
  191. if (taskResult.error) continue;
  192. var tasks = taskResult.tasks || [];
  193. for (var j = 0; j < tasks.length; j++) {
  194. tasks[j]._project_name = proj.name;
  195. }
  196. allTasks = allTasks.concat(tasks);
  197. }
  198. return filterTasks(allTasks, query);
  199. }
  200. function filterTasks(tasks, query) {
  201. var matched = [];
  202. var q = query.toLowerCase();
  203. for (var i = 0; i < tasks.length; i++) {
  204. var t = tasks[i];
  205. if ((t.name && t.name.toLowerCase().indexOf(q) !== -1) ||
  206. (t.description && t.description.toLowerCase().indexOf(q) !== -1)) {
  207. matched.push(t);
  208. }
  209. }
  210. return { tasks: matched, total: matched.length, query: query };
  211. }
  212. // --- Comments ---
  213. function listComments(token, projectId, taskId, index) {
  214. var params = {};
  215. if (index) params.index = String(index);
  216. return apiCall(token, 'GET',
  217. '/api/v3/portal/{PORTALID}/projects/{PROJECTID}/tasks/{TASKID}/comments'
  218. .replace('{PROJECTID}', projectId)
  219. .replace('{TASKID}', taskId), null, params);
  220. }
  221. function addComment(token, projectId, taskId, content) {
  222. return apiCall(token, 'POST',
  223. '/api/v3/portal/{PORTALID}/projects/{PROJECTID}/tasks/{TASKID}/comments'
  224. .replace('{PROJECTID}', projectId)
  225. .replace('{TASKID}', taskId), { content: content });
  226. }
  227. function updateComment(token, projectId, taskId, commentId, content) {
  228. return apiCall(token, 'PUT',
  229. '/api/v3/portal/{PORTALID}/projects/{PROJECTID}/tasks/{TASKID}/comments/{COMMENTID}'
  230. .replace('{PROJECTID}', projectId)
  231. .replace('{TASKID}', taskId)
  232. .replace('{COMMENTID}', commentId), { content: content });
  233. }
  234. function deleteComment(token, projectId, taskId, commentId) {
  235. return apiCall(token, 'DELETE',
  236. '/api/v3/portal/{PORTALID}/projects/{PROJECTID}/tasks/{TASKID}/comments/{COMMENTID}'
  237. .replace('{PROJECTID}', projectId)
  238. .replace('{TASKID}', taskId)
  239. .replace('{COMMENTID}', commentId), null, {});
  240. }
  241. // --- Milestones ---
  242. function listMilestones(token, projectId, filters) {
  243. var params = {};
  244. if (filters && filters.status) params.status = filters.status;
  245. if (filters && filters.index) params.index = String(filters.index);
  246. return apiCall(token, 'GET',
  247. '/api/v3/portal/{PORTALID}/projects/{PROJECTID}/milestones'
  248. .replace('{PROJECTID}', projectId), null, params);
  249. }
  250. function createMilestone(token, projectId, args) {
  251. var body = { name: args.name };
  252. if (args.start_date) body.start_date = args.start_date;
  253. if (args.end_date) body.end_date = args.end_date;
  254. if (args.owner) body.owner = String(args.owner);
  255. if (args.flag) body.flag = args.flag; // "internal" | "external"
  256. if (args.milestone_type) body.milestone_type = args.milestone_type;
  257. return apiCall(token, 'POST',
  258. '/api/v3/portal/{PORTALID}/projects/{PROJECTID}/milestones'
  259. .replace('{PROJECTID}', projectId), body);
  260. }
  261. function updateMilestone(token, projectId, milestoneId, args) {
  262. var body = {};
  263. if (args.name) body.name = args.name;
  264. if (args.start_date) body.start_date = args.start_date;
  265. if (args.end_date) body.end_date = args.end_date;
  266. if (args.owner) body.owner = String(args.owner);
  267. if (args.flag) body.flag = args.flag;
  268. if (args.status) body.status = args.status;
  269. return apiCall(token, 'PATCH',
  270. '/api/v3/portal/{PORTALID}/projects/{PROJECTID}/milestones/{MILESTONEID}'
  271. .replace('{PROJECTID}', projectId)
  272. .replace('{MILESTONEID}', milestoneId), body);
  273. }
  274. function deleteMilestone(token, projectId, milestoneId) {
  275. return apiCall(token, 'DELETE',
  276. '/api/v3/portal/{PORTALID}/projects/{PROJECTID}/milestones/{MILESTONEID}'
  277. .replace('{PROJECTID}', projectId)
  278. .replace('{MILESTONEID}', milestoneId), null, {});
  279. }
  280. // --- Tasklists ---
  281. function listTasklists(token, projectId, filters) {
  282. var params = {};
  283. if (filters && filters.flag) params.flag = filters.flag;
  284. if (filters && filters.index) params.index = String(filters.index);
  285. if (projectId) {
  286. return apiCall(token, 'GET',
  287. '/api/v3/portal/{PORTALID}/projects/{PROJECTID}/tasklists'
  288. .replace('{PROJECTID}', projectId), null, params);
  289. }
  290. // Portal-wide
  291. return apiCall(token, 'GET',
  292. '/api/v3/portal/{PORTALID}/all-tasklists', null, params);
  293. }
  294. function getTasklist(token, projectId, tasklistId) {
  295. return apiCall(token, 'GET',
  296. '/api/v3/portal/{PORTALID}/projects/{PROJECTID}/tasklists/{TASKLISTID}'
  297. .replace('{PROJECTID}', projectId)
  298. .replace('{TASKLISTID}', tasklistId), null, {});
  299. }
  300. function createTasklist(token, projectId, args) {
  301. var body = { name: args.name };
  302. if (args.milestone_id) body.milestone = { id: String(args.milestone_id) };
  303. if (args.flag) body.flag = args.flag; // "internal" | "external"
  304. if (args.status) body.status = args.status; // "active" | "archived"
  305. return apiCall(token, 'POST',
  306. '/api/v3/portal/{PORTALID}/projects/{PROJECTID}/tasklists'
  307. .replace('{PROJECTID}', projectId), body);
  308. }
  309. function updateTasklist(token, projectId, tasklistId, args) {
  310. var body = {};
  311. if (args.name) body.name = args.name;
  312. if (args.milestone_id) body.milestone = { id: String(args.milestone_id) };
  313. if (args.flag) body.flag = args.flag;
  314. if (args.status) body.status = args.status;
  315. return apiCall(token, 'PATCH',
  316. '/api/v3/portal/{PORTALID}/projects/{PROJECTID}/tasklists/{TASKLISTID}'
  317. .replace('{PROJECTID}', projectId)
  318. .replace('{TASKLISTID}', tasklistId), body);
  319. }
  320. function deleteTasklist(token, projectId, tasklistId) {
  321. return apiCall(token, 'DELETE',
  322. '/api/v3/portal/{PORTALID}/projects/{PROJECTID}/tasklists/{TASKLISTID}'
  323. .replace('{PROJECTID}', projectId)
  324. .replace('{TASKLISTID}', tasklistId), null, {});
  325. }
  326. // --- Users ---
  327. // Returns zpuid + name + email for every portal user. Needed before
  328. // assigning a task so you can resolve a name/email to a zpuid.
  329. function listUsers(token, filters) {
  330. var params = {};
  331. if (filters && filters.index) params.index = String(filters.index);
  332. if (filters && filters.user_type) params.user_type = filters.user_type;
  333. return apiCall(token, 'GET',
  334. '/api/v3/portal/{PORTALID}/users', null, params);
  335. }
  336. // --- Task Statuses ---
  337. // v3 task body expects status = { id: <statusId> }. IDs are project-local,
  338. // so call this first if you don't already know the target status's id.
  339. function listTaskStatuses(token, projectId) {
  340. return apiCall(token, 'GET',
  341. '/api/v3/portal/{PORTALID}/projects/{PROJECTID}/taskstatuses'
  342. .replace('{PROJECTID}', projectId), null, {});
  343. }
  344. // --- Timesheet ---
  345. function logTime(token, projectId, args) {
  346. var logDate = args.date;
  347. if (!logDate) {
  348. var now = new Date();
  349. logDate = now.getFullYear() + '-' +
  350. String(now.getMonth() + 1).padStart(2, '0') + '-' +
  351. String(now.getDate()).padStart(2, '0');
  352. } else if (logDate.length === 10 && logDate.charAt(2) === '-') {
  353. // MM-DD-YYYY → YYYY-MM-DD
  354. var parts = logDate.split('-');
  355. logDate = parts[2] + '-' + parts[0] + '-' + parts[1];
  356. }
  357. var totalHours = Number(args.hours) + (Number(args.minutes || 0) / 60);
  358. totalHours = Math.round(totalHours * 100) / 100;
  359. var body = {
  360. log_name: args.notes || 'Time logged',
  361. date: logDate,
  362. bill_status: args.bill_status || 'Billable',
  363. hours: String(totalHours),
  364. notes: args.notes || '',
  365. module: { id: String(args.task_id), type: 'task' },
  366. for_timer: false,
  367. frompage: 'taskdetails'
  368. };
  369. return apiCall(token, 'POST',
  370. '/api/v3/portal/{PORTALID}/projects/{PROJECTID}/log'
  371. .replace('{PROJECTID}', projectId), body);
  372. }
  373. function updateTimesheet(token, projectId, logId, args) {
  374. var body = { module: 'task', from_page: 'timesheetdetails' };
  375. if (args.hours !== undefined) {
  376. var h = Number(args.hours) + (Number(args.minutes || 0) / 60);
  377. body.hours = String(Math.round(h * 100) / 100);
  378. }
  379. if (args.notes !== undefined) body.notes = args.notes;
  380. if (args.log_name !== undefined) body.log_name = args.log_name;
  381. if (args.bill_status) body.bill_status = args.bill_status;
  382. if (args.date) {
  383. var d = args.date;
  384. if (d.length === 10 && d.charAt(2) === '-') {
  385. var parts = d.split('-');
  386. d = parts[2] + '-' + parts[0] + '-' + parts[1];
  387. }
  388. body.date = d;
  389. }
  390. return apiCall(token, 'PATCH',
  391. '/api/v3/portal/{PORTALID}/projects/{PROJECTID}/logs/{LOGID}'
  392. .replace('{PROJECTID}', projectId)
  393. .replace('{LOGID}', String(logId)), body);
  394. }
  395. function listTimesheets(token, projectId, filters) {
  396. var portalId = shadowman.config.value('portal_id');
  397. if (!portalId) return { error: 'Missing portal_id in plugin config.' };
  398. var params = { view_type: 'customdate' };
  399. if (filters.from_date) {
  400. var parts = filters.from_date.split('-');
  401. if (parts.length === 3) params.start_date = parts[2] + '-' + parts[0] + '-' + parts[1];
  402. else params.start_date = filters.from_date;
  403. } else {
  404. var d = new Date();
  405. d.setDate(d.getDate() - 30);
  406. params.start_date = d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0');
  407. }
  408. if (filters.to_date) {
  409. var parts2 = filters.to_date.split('-');
  410. if (parts2.length === 3) params.end_date = parts2[2] + '-' + parts2[0] + '-' + parts2[1];
  411. else params.end_date = filters.to_date;
  412. } else {
  413. var today = new Date();
  414. params.end_date = today.getFullYear() + '-' + String(today.getMonth() + 1).padStart(2, '0') + '-' + String(today.getDate()).padStart(2, '0');
  415. }
  416. if (filters.index) params.page = String(filters.index);
  417. if (filters.task_id) {
  418. params.module = JSON.stringify({ id: String(filters.task_id), type: 'task' });
  419. } else {
  420. params.module = JSON.stringify({ type: 'task' });
  421. }
  422. var url = '/api/v3/portal/{PORTALID}/timelogs';
  423. if (projectId) {
  424. url = '/api/v3/portal/{PORTALID}/projects/{PROJECTID}/timelogs'
  425. .replace('{PROJECTID}', projectId);
  426. }
  427. return apiCall(token, 'GET', url, null, params);
  428. }
  429. function deleteTimesheet(token, projectId, logId) {
  430. return apiCall(token, 'DELETE',
  431. '/api/v3/portal/{PORTALID}/projects/{PROJECTID}/logs/{LOGID}'
  432. .replace('{PROJECTID}', projectId)
  433. .replace('{LOGID}', String(logId)), { module: 'task', from_page: 'timesheetdetails' }, {});
  434. }
  435. module.exports = {
  436. // Projects
  437. listProjects: listProjects,
  438. getProject: getProject,
  439. // Tasks
  440. listTasks: listTasks,
  441. getTask: getTask,
  442. createTask: createTask,
  443. updateTask: updateTask,
  444. deleteTask: deleteTask,
  445. listSubtasks: listSubtasks,
  446. getMyTasks: getMyTasks,
  447. searchTasks: searchTasks,
  448. // Comments
  449. listComments: listComments,
  450. addComment: addComment,
  451. updateComment: updateComment,
  452. deleteComment: deleteComment,
  453. // Milestones
  454. listMilestones: listMilestones,
  455. createMilestone: createMilestone,
  456. updateMilestone: updateMilestone,
  457. deleteMilestone: deleteMilestone,
  458. // Tasklists
  459. listTasklists: listTasklists,
  460. getTasklist: getTasklist,
  461. createTasklist: createTasklist,
  462. updateTasklist: updateTasklist,
  463. deleteTasklist: deleteTasklist,
  464. // Users + Statuses
  465. listUsers: listUsers,
  466. listTaskStatuses: listTaskStatuses,
  467. // Timesheet
  468. logTime: logTime,
  469. updateTimesheet: updateTimesheet,
  470. listTimesheets: listTimesheets,
  471. deleteTimesheet: deleteTimesheet
  472. };