// 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 --- 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 params = {}; if (index) params.index = String(index); return apiCall(token, 'GET', '/api/v3/portal/{PORTALID}/projects', null, params); } function getProject(token, projectId) { return apiCall(token, 'GET', '/api/v3/portal/{PORTALID}/projects/{PROJECTID}' .replace('{PROJECTID}', projectId), null, {}); } // --- Tasks --- 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 !== undefined) body.completion_percentage = String(args.percent_complete); // v3 extras if (args.status_id) body.status = { id: String(args.status_id) }; if (args.parent_task_id) body.parental_info = { parent_task_id: String(args.parent_task_id) }; if (args.billing_type) body.billing_type = args.billing_type; 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); if (args.status_id) body.status = { id: String(args.status_id) }; if (args.tasklist_id) body.tasklist = { id: String(args.tasklist_id) }; if (args.milestone_id) body.milestone_id = String(args.milestone_id); if (args.billing_type) body.billing_type = args.billing_type; 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, {}); } function listSubtasks(token, projectId, taskId) { return apiCall(token, 'GET', '/api/v3/portal/{PORTALID}/projects/{PROJECTID}/tasks/{TASKID}/subtasks' .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 listComments(token, projectId, taskId, index) { var params = {}; if (index) params.index = String(index); return apiCall(token, 'GET', '/api/v3/portal/{PORTALID}/projects/{PROJECTID}/tasks/{TASKID}/comments' .replace('{PROJECTID}', projectId) .replace('{TASKID}', taskId), null, params); } 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, {}); } // --- Milestones --- function listMilestones(token, projectId, filters) { var params = {}; if (filters && filters.status) params.status = filters.status; if (filters && filters.index) params.index = String(filters.index); return apiCall(token, 'GET', '/api/v3/portal/{PORTALID}/projects/{PROJECTID}/milestones' .replace('{PROJECTID}', projectId), null, params); } function createMilestone(token, projectId, args) { var body = { name: args.name }; if (args.start_date) body.start_date = args.start_date; if (args.end_date) body.end_date = args.end_date; if (args.owner) body.owner = String(args.owner); if (args.flag) body.flag = args.flag; // "internal" | "external" if (args.milestone_type) body.milestone_type = args.milestone_type; return apiCall(token, 'POST', '/api/v3/portal/{PORTALID}/projects/{PROJECTID}/milestones' .replace('{PROJECTID}', projectId), body); } function updateMilestone(token, projectId, milestoneId, args) { var body = {}; if (args.name) body.name = args.name; if (args.start_date) body.start_date = args.start_date; if (args.end_date) body.end_date = args.end_date; if (args.owner) body.owner = String(args.owner); if (args.flag) body.flag = args.flag; if (args.status) body.status = args.status; return apiCall(token, 'PATCH', '/api/v3/portal/{PORTALID}/projects/{PROJECTID}/milestones/{MILESTONEID}' .replace('{PROJECTID}', projectId) .replace('{MILESTONEID}', milestoneId), body); } function deleteMilestone(token, projectId, milestoneId) { return apiCall(token, 'DELETE', '/api/v3/portal/{PORTALID}/projects/{PROJECTID}/milestones/{MILESTONEID}' .replace('{PROJECTID}', projectId) .replace('{MILESTONEID}', milestoneId), null, {}); } // --- Tasklists --- function listTasklists(token, projectId, filters) { var params = {}; if (filters && filters.flag) params.flag = filters.flag; if (filters && filters.index) params.index = String(filters.index); if (projectId) { return apiCall(token, 'GET', '/api/v3/portal/{PORTALID}/projects/{PROJECTID}/tasklists' .replace('{PROJECTID}', projectId), null, params); } // Portal-wide return apiCall(token, 'GET', '/api/v3/portal/{PORTALID}/all-tasklists', null, params); } function getTasklist(token, projectId, tasklistId) { return apiCall(token, 'GET', '/api/v3/portal/{PORTALID}/projects/{PROJECTID}/tasklists/{TASKLISTID}' .replace('{PROJECTID}', projectId) .replace('{TASKLISTID}', tasklistId), null, {}); } function createTasklist(token, projectId, args) { var body = { name: args.name }; if (args.milestone_id) body.milestone = { id: String(args.milestone_id) }; if (args.flag) body.flag = args.flag; // "internal" | "external" if (args.status) body.status = args.status; // "active" | "archived" return apiCall(token, 'POST', '/api/v3/portal/{PORTALID}/projects/{PROJECTID}/tasklists' .replace('{PROJECTID}', projectId), body); } function updateTasklist(token, projectId, tasklistId, args) { var body = {}; if (args.name) body.name = args.name; if (args.milestone_id) body.milestone = { id: String(args.milestone_id) }; if (args.flag) body.flag = args.flag; if (args.status) body.status = args.status; return apiCall(token, 'PATCH', '/api/v3/portal/{PORTALID}/projects/{PROJECTID}/tasklists/{TASKLISTID}' .replace('{PROJECTID}', projectId) .replace('{TASKLISTID}', tasklistId), body); } function deleteTasklist(token, projectId, tasklistId) { return apiCall(token, 'DELETE', '/api/v3/portal/{PORTALID}/projects/{PROJECTID}/tasklists/{TASKLISTID}' .replace('{PROJECTID}', projectId) .replace('{TASKLISTID}', tasklistId), null, {}); } // --- Users --- // Returns zpuid + name + email for every portal user. Needed before // assigning a task so you can resolve a name/email to a zpuid. function listUsers(token, filters) { var params = {}; if (filters && filters.index) params.index = String(filters.index); if (filters && filters.user_type) params.user_type = filters.user_type; return apiCall(token, 'GET', '/api/v3/portal/{PORTALID}/users', null, params); } // --- Task Statuses --- // v3 task body expects status = { id: }. IDs are project-local, // so call this first if you don't already know the target status's id. function listTaskStatuses(token, projectId) { return apiCall(token, 'GET', '/api/v3/portal/{PORTALID}/projects/{PROJECTID}/taskstatuses' .replace('{PROJECTID}', projectId), null, {}); } // --- Timesheet --- function logTime(token, projectId, args) { var logDate = args.date; if (!logDate) { var now = new Date(); logDate = now.getFullYear() + '-' + String(now.getMonth() + 1).padStart(2, '0') + '-' + String(now.getDate()).padStart(2, '0'); } else if (logDate.length === 10 && logDate.charAt(2) === '-') { // MM-DD-YYYY → YYYY-MM-DD 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: args.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 updateTimesheet(token, projectId, logId, args) { var body = { module: 'task', from_page: 'timesheetdetails' }; if (args.hours !== undefined) { var h = Number(args.hours) + (Number(args.minutes || 0) / 60); body.hours = String(Math.round(h * 100) / 100); } if (args.notes !== undefined) body.notes = args.notes; if (args.log_name !== undefined) body.log_name = args.log_name; if (args.bill_status) body.bill_status = args.bill_status; if (args.date) { var d = args.date; if (d.length === 10 && d.charAt(2) === '-') { var parts = d.split('-'); d = parts[2] + '-' + parts[0] + '-' + parts[1]; } body.date = d; } return apiCall(token, 'PATCH', '/api/v3/portal/{PORTALID}/projects/{PROJECTID}/logs/{LOGID}' .replace('{PROJECTID}', projectId) .replace('{LOGID}', String(logId)), body); } function listTimesheets(token, projectId, filters) { var portalId = shadowman.config.value('portal_id'); if (!portalId) return { error: 'Missing portal_id in plugin config.' }; var params = { view_type: 'customdate' }; if (filters.from_date) { var parts = filters.from_date.split('-'); if (parts.length === 3) params.start_date = parts[2] + '-' + parts[0] + '-' + parts[1]; else params.start_date = filters.from_date; } else { var d = new Date(); d.setDate(d.getDate() - 30); params.start_date = d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0'); } if (filters.to_date) { var parts2 = filters.to_date.split('-'); if (parts2.length === 3) params.end_date = parts2[2] + '-' + parts2[0] + '-' + parts2[1]; else params.end_date = filters.to_date; } else { var today = new Date(); params.end_date = today.getFullYear() + '-' + String(today.getMonth() + 1).padStart(2, '0') + '-' + String(today.getDate()).padStart(2, '0'); } if (filters.index) params.page = String(filters.index); if (filters.task_id) { params.module = JSON.stringify({ id: String(filters.task_id), type: 'task' }); } else { params.module = JSON.stringify({ type: 'task' }); } var url = '/api/v3/portal/{PORTALID}/timelogs'; if (projectId) { url = '/api/v3/portal/{PORTALID}/projects/{PROJECTID}/timelogs' .replace('{PROJECTID}', projectId); } return apiCall(token, 'GET', url, null, params); } function deleteTimesheet(token, projectId, logId) { return apiCall(token, 'DELETE', '/api/v3/portal/{PORTALID}/projects/{PROJECTID}/logs/{LOGID}' .replace('{PROJECTID}', projectId) .replace('{LOGID}', String(logId)), { module: 'task', from_page: 'timesheetdetails' }, {}); } module.exports = { // Projects listProjects: listProjects, getProject: getProject, // Tasks listTasks: listTasks, getTask: getTask, createTask: createTask, updateTask: updateTask, deleteTask: deleteTask, listSubtasks: listSubtasks, getMyTasks: getMyTasks, searchTasks: searchTasks, // Comments listComments: listComments, addComment: addComment, updateComment: updateComment, deleteComment: deleteComment, // Milestones listMilestones: listMilestones, createMilestone: createMilestone, updateMilestone: updateMilestone, deleteMilestone: deleteMilestone, // Tasklists listTasklists: listTasklists, getTasklist: getTasklist, createTasklist: createTasklist, updateTasklist: updateTasklist, deleteTasklist: deleteTasklist, // Users + Statuses listUsers: listUsers, listTaskStatuses: listTaskStatuses, // Timesheet logTime: logTime, updateTimesheet: updateTimesheet, listTimesheets: listTimesheets, deleteTimesheet: deleteTimesheet };