// Zoho Projects v3 API Module // EU data center: https://projects.zoho.eu // Collection endpoints: no trailing slash. Resource endpoints: trailing slash optional. var API_BASE = 'https://projects.zoho.eu'; function makeRequest(token, method, path, jsonBody, queryParams) { var portalId = shadowman.config.value('portal_id'); if (!portalId) { return { error: 'Missing portal_id in plugin config. Run resolve_portal first.' }; } var url = API_BASE + path.replace('{PORTALID}', portalId); if (queryParams) { var qs = []; for (var key in queryParams) { if (queryParams.hasOwnProperty(key) && queryParams[key] !== undefined) { qs.push(encodeURIComponent(key) + '=' + encodeURIComponent(queryParams[key])); } } if (qs.length > 0) { url += '?' + qs.join('&'); } } var hasBody = (jsonBody !== null && jsonBody !== undefined); var headers = { 'Authorization': 'Zoho-oauthtoken ' + token }; if (hasBody) { headers['Content-Type'] = 'application/json'; } var resp = shadowman.http.request(url, { method: method, body: hasBody ? JSON.stringify(jsonBody) : null, headers: headers, contentType: hasBody ? 'application/json' : null }); if (resp.status === 401) { return { _tokenExpired: true }; } if (resp.status >= 400) { var raw = resp.body; var msg; if (typeof raw === 'string') { try { var p = JSON.parse(raw); msg = p.error && p.error.message ? p.error.message : raw; } catch (e) { msg = raw; } } else if (raw && typeof raw === 'object') { msg = (raw.error && raw.error.message) || raw.message || raw.description || JSON.stringify(raw); } else { msg = String(raw); } return { error: 'Zoho API error (' + resp.status + '): ' + msg }; } if (resp.body && typeof resp.body === 'object') { return resp.body; } if (!resp.body || (typeof resp.body === 'string' && resp.body.trim() === '')) { return { success: true }; } try { return JSON.parse(resp.body); } catch (e) { return { error: 'Invalid JSON response: ' + resp.body }; } } function apiCall(token, method, path, jsonBody, queryParams) { var result = makeRequest(token, method, path, jsonBody, queryParams); if (result && result._tokenExpired) { var auth = require('./auth'); var newToken = auth.getValidToken(); if (!newToken) { return { error: 'Authentication expired and refresh failed. Re-run setup_auth.' }; } return makeRequest(newToken, method, path, jsonBody, queryParams); } return result; } // --- Projects (v1 API — v3 returns 400) --- function listProjects(token, index) { var portalId = shadowman.config.value('portal_id'); if (!portalId) { return { error: 'Missing portal_id in plugin config. Run resolve_portal first.' }; } var url = API_BASE + '/restapi/portal/' + portalId + '/projects/?index=' + encodeURIComponent(String(index)); var headers = { 'Authorization': 'Zoho-oauthtoken ' + token }; var resp = shadowman.http.request(url, { method: 'GET', headers: headers }); if (resp.status === 401) { var auth = require('./auth'); var newToken = auth.getValidToken(); if (!newToken) return { error: 'Authentication expired and refresh failed. Re-run setup_auth.' }; headers['Authorization'] = 'Zoho-oauthtoken ' + newToken; resp = shadowman.http.request(url, { method: 'GET', headers: headers }); } if (resp.status >= 400) return { error: 'Zoho API error (' + resp.status + '): ' + resp.body }; try { return JSON.parse(resp.body); } catch (e) { return { error: 'Invalid JSON: ' + resp.body }; } } // --- Tasks (v3) --- function listTasks(token, projectId, filters) { var params = {}; if (filters.status) params.status = filters.status; if (filters.priority) params.priority = filters.priority; if (filters.owner) params.owner = filters.owner; if (filters.index) params.index = String(filters.index); if (filters.milestone_id) params.milestone_id = filters.milestone_id; if (filters.tasklist_id) params.tasklist_id = filters.tasklist_id; return apiCall(token, 'GET', '/api/v3/portal/{PORTALID}/projects/{PROJECTID}/tasks' .replace('{PROJECTID}', projectId), null, params); } function getTask(token, projectId, taskId) { return apiCall(token, 'GET', '/api/v3/portal/{PORTALID}/projects/{PROJECTID}/tasks/{TASKID}' .replace('{PROJECTID}', projectId) .replace('{TASKID}', taskId), null, {}); } function createTask(token, projectId, args) { var body = { name: args.name }; if (args.description) body.description = args.description; if (args.person_responsible) body.person_responsible = String(args.person_responsible); if (args.priority) body.priority = args.priority; if (args.start_date) body.start_date = args.start_date; if (args.end_date) body.end_date = args.end_date; if (args.tasklist_id) body.tasklist_id = String(args.tasklist_id); if (args.milestone_id) body.milestone_id = String(args.milestone_id); if (args.percent_complete) body.completion_percentage = String(args.percent_complete); return apiCall(token, 'POST', '/api/v3/portal/{PORTALID}/projects/{PROJECTID}/tasks' .replace('{PROJECTID}', projectId), body); } function updateTask(token, projectId, taskId, args) { var body = {}; if (args.name) body.name = args.name; if (args.description !== undefined) body.description = args.description; if (args.person_responsible) body.person_responsible = String(args.person_responsible); if (args.priority) body.priority = args.priority; if (args.start_date) body.start_date = args.start_date; if (args.end_date) body.end_date = args.end_date; if (args.percent_complete !== undefined) body.completion_percentage = String(args.percent_complete); return apiCall(token, 'PATCH', '/api/v3/portal/{PORTALID}/projects/{PROJECTID}/tasks/{TASKID}' .replace('{PROJECTID}', projectId) .replace('{TASKID}', taskId), body); } function deleteTask(token, projectId, taskId) { return apiCall(token, 'DELETE', '/api/v3/portal/{PORTALID}/projects/{PROJECTID}/tasks/{TASKID}' .replace('{PROJECTID}', projectId) .replace('{TASKID}', taskId), null, {}); } // --- My Tasks --- function getMyTasks(token, filters) { var params = {}; if (filters.status) params.status = filters.status; if (filters.priority) params.priority = filters.priority; if (filters.index) params.index = String(filters.index); return apiCall(token, 'GET', '/api/v3/portal/{PORTALID}/mytasks', null, params); } // --- Search --- function searchTasks(token, args) { var portalId = shadowman.config.value('portal_id'); if (!portalId) return { error: 'Missing portal_id in plugin config.' }; var query = args.query; var projectId = args.project_id; if (projectId) { var result = apiCall(token, 'GET', '/api/v3/portal/{PORTALID}/projects/{PROJECTID}/tasks' .replace('{PROJECTID}', projectId), null, { index: '1' }); if (result.error) return result; return filterTasks(result.tasks || [], query); } var projects = listProjects(token, 1); if (projects.error) return projects; var allTasks = []; var projectList = projects.projects || []; for (var p = 0; p < projectList.length; p++) { var proj = projectList[p]; var taskResult = apiCall(token, 'GET', '/api/v3/portal/{PORTALID}/projects/{PROJECTID}/tasks' .replace('{PROJECTID}', String(proj.id)), null, { index: '1' }); if (taskResult.error) continue; var tasks = taskResult.tasks || []; for (var j = 0; j < tasks.length; j++) { tasks[j]._project_name = proj.name; } allTasks = allTasks.concat(tasks); } return filterTasks(allTasks, query); } function filterTasks(tasks, query) { var matched = []; var q = query.toLowerCase(); for (var i = 0; i < tasks.length; i++) { var t = tasks[i]; if ((t.name && t.name.toLowerCase().indexOf(q) !== -1) || (t.description && t.description.toLowerCase().indexOf(q) !== -1)) { matched.push(t); } } return { tasks: matched, total: matched.length, query: query }; } // --- Comments --- function addComment(token, projectId, taskId, content) { return apiCall(token, 'POST', '/api/v3/portal/{PORTALID}/projects/{PROJECTID}/tasks/{TASKID}/comments' .replace('{PROJECTID}', projectId) .replace('{TASKID}', taskId), { content: content }); } function updateComment(token, projectId, taskId, commentId, content) { return apiCall(token, 'PUT', '/api/v3/portal/{PORTALID}/projects/{PROJECTID}/tasks/{TASKID}/comments/{COMMENTID}' .replace('{PROJECTID}', projectId) .replace('{TASKID}', taskId) .replace('{COMMENTID}', commentId), { content: content }); } function deleteComment(token, projectId, taskId, commentId) { return apiCall(token, 'DELETE', '/api/v3/portal/{PORTALID}/projects/{PROJECTID}/tasks/{TASKID}/comments/{COMMENTID}' .replace('{PROJECTID}', projectId) .replace('{TASKID}', taskId) .replace('{COMMENTID}', commentId), null, {}); } // --- Timesheet --- function logTime(token, projectId, args) { var logDate = args.date || '2026-04-15'; if (logDate.indexOf('-') === 2) { var parts = logDate.split('-'); logDate = parts[2] + '-' + parts[0] + '-' + parts[1]; } var totalHours = Number(args.hours) + (Number(args.minutes || 0) / 60); totalHours = Math.round(totalHours * 100) / 100; var body = { log_name: args.notes || 'Time logged', date: logDate, bill_status: 'Billable', hours: String(totalHours), notes: args.notes || '', module: { id: String(args.task_id), type: 'task' }, for_timer: false, frompage: 'taskdetails' }; return apiCall(token, 'POST', '/api/v3/portal/{PORTALID}/projects/{PROJECTID}/log' .replace('{PROJECTID}', projectId), body); } function listTimesheets(token, projectId, filters) { var params = {}; if (filters.task_id) params.task_id = String(filters.task_id); if (filters.from_date) params.from_date = filters.from_date; if (filters.to_date) params.to_date = filters.to_date; if (filters.index) params.index = String(filters.index); return apiCall(token, 'GET', '/api/v3/portal/{PORTALID}/projects/{PROJECTID}/logs' .replace('{PROJECTID}', projectId), null, params); } function deleteTimesheet(token, projectId, logId) { return apiCall(token, 'DELETE', '/api/v3/portal/{PORTALID}/projects/{PROJECTID}/logs/{LOGID}' .replace('{PORTALID}', shadowman.config.value('portal_id')) .replace('{PROJECTID}', projectId) .replace('{LOGID}', String(logId)), { module: 'task', from_page: 'timesheetdetails' }, {}); } module.exports = { listProjects: listProjects, listTasks: listTasks, getTask: getTask, createTask: createTask, updateTask: updateTask, deleteTask: deleteTask, getMyTasks: getMyTasks, searchTasks: searchTasks, addComment: addComment, updateComment: updateComment, deleteComment: deleteComment, logTime: logTime, listTimesheets: listTimesheets, deleteTimesheet: deleteTimesheet };