tasks.js 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364
  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 (v1 API — v3 returns 400) ---
  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 url = API_BASE + '/restapi/portal/' + portalId + '/projects/?index=' + encodeURIComponent(String(index));
  86. var headers = { 'Authorization': 'Zoho-oauthtoken ' + token };
  87. var resp = shadowman.http.request(url, { method: 'GET', headers: headers });
  88. if (resp.status === 401) {
  89. var auth = require('./auth');
  90. var newToken = auth.getValidToken();
  91. if (!newToken) return { error: 'Authentication expired and refresh failed. Re-run setup_auth.' };
  92. headers['Authorization'] = 'Zoho-oauthtoken ' + newToken;
  93. resp = shadowman.http.request(url, { method: 'GET', headers: headers });
  94. }
  95. if (resp.status >= 400) return { error: 'Zoho API error (' + resp.status + '): ' + resp.body };
  96. try { return JSON.parse(resp.body); } catch (e) { return { error: 'Invalid JSON: ' + resp.body }; }
  97. }
  98. // --- Tasks (v3) ---
  99. function listTasks(token, projectId, filters) {
  100. var params = {};
  101. if (filters.status) params.status = filters.status;
  102. if (filters.priority) params.priority = filters.priority;
  103. if (filters.owner) params.owner = filters.owner;
  104. if (filters.index) params.index = String(filters.index);
  105. if (filters.milestone_id) params.milestone_id = filters.milestone_id;
  106. if (filters.tasklist_id) params.tasklist_id = filters.tasklist_id;
  107. return apiCall(token, 'GET',
  108. '/api/v3/portal/{PORTALID}/projects/{PROJECTID}/tasks'
  109. .replace('{PROJECTID}', projectId), null, params);
  110. }
  111. function getTask(token, projectId, taskId) {
  112. return apiCall(token, 'GET',
  113. '/api/v3/portal/{PORTALID}/projects/{PROJECTID}/tasks/{TASKID}'
  114. .replace('{PROJECTID}', projectId)
  115. .replace('{TASKID}', taskId), null, {});
  116. }
  117. function createTask(token, projectId, args) {
  118. var body = { name: args.name };
  119. if (args.description) body.description = args.description;
  120. if (args.person_responsible) body.person_responsible = String(args.person_responsible);
  121. if (args.priority) body.priority = args.priority;
  122. if (args.start_date) body.start_date = args.start_date;
  123. if (args.end_date) body.end_date = args.end_date;
  124. if (args.tasklist_id) body.tasklist_id = String(args.tasklist_id);
  125. if (args.milestone_id) body.milestone_id = String(args.milestone_id);
  126. if (args.percent_complete) body.completion_percentage = String(args.percent_complete);
  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. return apiCall(token, 'PATCH',
  141. '/api/v3/portal/{PORTALID}/projects/{PROJECTID}/tasks/{TASKID}'
  142. .replace('{PROJECTID}', projectId)
  143. .replace('{TASKID}', taskId), body);
  144. }
  145. function deleteTask(token, projectId, taskId) {
  146. return apiCall(token, 'DELETE',
  147. '/api/v3/portal/{PORTALID}/projects/{PROJECTID}/tasks/{TASKID}'
  148. .replace('{PROJECTID}', projectId)
  149. .replace('{TASKID}', taskId), null, {});
  150. }
  151. // --- My Tasks ---
  152. function getMyTasks(token, filters) {
  153. var params = {};
  154. if (filters.status) params.status = filters.status;
  155. if (filters.priority) params.priority = filters.priority;
  156. if (filters.index) params.index = String(filters.index);
  157. return apiCall(token, 'GET', '/api/v3/portal/{PORTALID}/mytasks', null, params);
  158. }
  159. // --- Search ---
  160. function searchTasks(token, args) {
  161. var portalId = shadowman.config.value('portal_id');
  162. if (!portalId) return { error: 'Missing portal_id in plugin config.' };
  163. var query = args.query;
  164. var projectId = args.project_id;
  165. if (projectId) {
  166. var result = apiCall(token, 'GET',
  167. '/api/v3/portal/{PORTALID}/projects/{PROJECTID}/tasks'
  168. .replace('{PROJECTID}', projectId), null, { index: '1' });
  169. if (result.error) return result;
  170. return filterTasks(result.tasks || [], query);
  171. }
  172. var projects = listProjects(token, 1);
  173. if (projects.error) return projects;
  174. var allTasks = [];
  175. var projectList = projects.projects || [];
  176. for (var p = 0; p < projectList.length; p++) {
  177. var proj = projectList[p];
  178. var taskResult = apiCall(token, 'GET',
  179. '/api/v3/portal/{PORTALID}/projects/{PROJECTID}/tasks'
  180. .replace('{PROJECTID}', String(proj.id)), null, { index: '1' });
  181. if (taskResult.error) continue;
  182. var tasks = taskResult.tasks || [];
  183. for (var j = 0; j < tasks.length; j++) {
  184. tasks[j]._project_name = proj.name;
  185. }
  186. allTasks = allTasks.concat(tasks);
  187. }
  188. return filterTasks(allTasks, query);
  189. }
  190. function filterTasks(tasks, query) {
  191. var matched = [];
  192. var q = query.toLowerCase();
  193. for (var i = 0; i < tasks.length; i++) {
  194. var t = tasks[i];
  195. if ((t.name && t.name.toLowerCase().indexOf(q) !== -1) ||
  196. (t.description && t.description.toLowerCase().indexOf(q) !== -1)) {
  197. matched.push(t);
  198. }
  199. }
  200. return { tasks: matched, total: matched.length, query: query };
  201. }
  202. // --- Comments ---
  203. function addComment(token, projectId, taskId, content) {
  204. return apiCall(token, 'POST',
  205. '/api/v3/portal/{PORTALID}/projects/{PROJECTID}/tasks/{TASKID}/comments'
  206. .replace('{PROJECTID}', projectId)
  207. .replace('{TASKID}', taskId), { content: content });
  208. }
  209. function updateComment(token, projectId, taskId, commentId, content) {
  210. return apiCall(token, 'PUT',
  211. '/api/v3/portal/{PORTALID}/projects/{PROJECTID}/tasks/{TASKID}/comments/{COMMENTID}'
  212. .replace('{PROJECTID}', projectId)
  213. .replace('{TASKID}', taskId)
  214. .replace('{COMMENTID}', commentId), { content: content });
  215. }
  216. function deleteComment(token, projectId, taskId, commentId) {
  217. return apiCall(token, 'DELETE',
  218. '/api/v3/portal/{PORTALID}/projects/{PROJECTID}/tasks/{TASKID}/comments/{COMMENTID}'
  219. .replace('{PROJECTID}', projectId)
  220. .replace('{TASKID}', taskId)
  221. .replace('{COMMENTID}', commentId), null, {});
  222. }
  223. // --- Timesheet ---
  224. function logTime(token, projectId, args) {
  225. var logDate = args.date || '2026-04-15';
  226. if (logDate.indexOf('-') === 2) {
  227. var parts = logDate.split('-');
  228. logDate = parts[2] + '-' + parts[0] + '-' + parts[1];
  229. }
  230. var totalHours = Number(args.hours) + (Number(args.minutes || 0) / 60);
  231. totalHours = Math.round(totalHours * 100) / 100;
  232. var body = {
  233. log_name: args.notes || 'Time logged',
  234. date: logDate,
  235. bill_status: 'Billable',
  236. hours: String(totalHours),
  237. notes: args.notes || '',
  238. module: { id: String(args.task_id), type: 'task' },
  239. for_timer: false,
  240. frompage: 'taskdetails'
  241. };
  242. return apiCall(token, 'POST',
  243. '/api/v3/portal/{PORTALID}/projects/{PROJECTID}/log'
  244. .replace('{PROJECTID}', projectId), body);
  245. }
  246. function listTimesheets(token, projectId, filters) {
  247. var params = {};
  248. if (filters.task_id) params.task_id = String(filters.task_id);
  249. if (filters.from_date) params.from_date = filters.from_date;
  250. if (filters.to_date) params.to_date = filters.to_date;
  251. if (filters.index) params.index = String(filters.index);
  252. return apiCall(token, 'GET',
  253. '/api/v3/portal/{PORTALID}/projects/{PROJECTID}/logs'
  254. .replace('{PROJECTID}', projectId), null, params);
  255. }
  256. function deleteTimesheet(token, projectId, logId) {
  257. // v3 API DELETE — requires JSON body: {module, from_page}
  258. // NOTE: If this fails with INVALID_INPUTSTREAM, the SDK may not send DELETE body correctly.
  259. var portalId = shadowman.config.value('portal_id');
  260. var url = API_BASE + '/api/v3/portal/' + portalId + '/projects/' + projectId + '/logs/' + String(logId);
  261. var body = JSON.stringify({ module: 'task', from_page: 'timesheetdetails' });
  262. var headers = {
  263. 'Authorization': 'Zoho-oauthtoken ' + token,
  264. 'Content-Type': 'application/json'
  265. };
  266. var resp = shadowman.http.request(url, {
  267. method: 'DELETE',
  268. body: body,
  269. headers: headers,
  270. contentType: 'application/json'
  271. });
  272. if (resp.status === 401) {
  273. var auth = require('./auth');
  274. var newToken = auth.getValidToken();
  275. if (!newToken) return { error: 'Auth expired and refresh failed.' };
  276. headers['Authorization'] = 'Zoho-oauthtoken ' + newToken;
  277. resp = shadowman.http.request(url, {
  278. method: 'DELETE',
  279. body: body,
  280. headers: headers,
  281. contentType: 'application/json'
  282. });
  283. }
  284. if (resp.status >= 400) {
  285. var msg = '';
  286. if (resp.body && typeof resp.body === 'object') {
  287. msg = (resp.body.error && resp.body.error.message) || JSON.stringify(resp.body);
  288. } else {
  289. msg = String(resp.body);
  290. }
  291. return { error: 'Zoho API error (' + resp.status + '): ' + msg };
  292. }
  293. return { success: true };
  294. }
  295. module.exports = {
  296. listProjects: listProjects,
  297. listTasks: listTasks,
  298. getTask: getTask,
  299. createTask: createTask,
  300. updateTask: updateTask,
  301. deleteTask: deleteTask,
  302. getMyTasks: getMyTasks,
  303. searchTasks: searchTasks,
  304. addComment: addComment,
  305. updateComment: updateComment,
  306. deleteComment: deleteComment,
  307. logTime: logTime,
  308. listTimesheets: listTimesheets,
  309. deleteTimesheet: deleteTimesheet
  310. };