|
@@ -0,0 +1,275 @@
|
|
|
|
|
+// Zoho Projects Tasks API Module
|
|
|
|
|
+// EU data center base: https://projectsapi.zoho.eu
|
|
|
|
|
+
|
|
|
|
|
+var API_BASE = 'https://projectsapi.zoho.eu';
|
|
|
|
|
+
|
|
|
|
|
+function makeRequest(token, method, path, params) {
|
|
|
|
|
+ var portalId = shadowman.config.value('portal_id');
|
|
|
|
|
+ if (!portalId) {
|
|
|
|
|
+ return null; // Let the caller handle missing portal ID
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Use Zoho Projects API domain. EU data center: projectsapi.zoho.eu
|
|
|
|
|
+ // Note: auth may return api_domain as www.zohoapis.eu (generic), but Projects
|
|
|
|
|
+ // requires its own domain.
|
|
|
|
|
+ var apiBase = 'https://projectsapi.zoho.eu';
|
|
|
|
|
+
|
|
|
|
|
+ // Replace placeholders in path
|
|
|
|
|
+ var url = path.replace('{PORTALID}', portalId);
|
|
|
|
|
+ var fullUrl = apiBase + url;
|
|
|
|
|
+
|
|
|
|
|
+ var headers = {
|
|
|
|
|
+ 'Authorization': 'Zoho-oauthtoken ' + token,
|
|
|
|
|
+ 'Content-Type': 'application/x-www-form-urlencoded'
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ var body = null;
|
|
|
|
|
+ var contentType = 'application/x-www-form-urlencoded';
|
|
|
|
|
+
|
|
|
|
|
+ if (method === 'GET' && params) {
|
|
|
|
|
+ var qs = [];
|
|
|
|
|
+ for (var key in params) {
|
|
|
|
|
+ if (params.hasOwnProperty(key) && params[key] !== undefined) {
|
|
|
|
|
+ qs.push(encodeURIComponent(key) + '=' + encodeURIComponent(params[key]));
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ if (qs.length > 0) {
|
|
|
|
|
+ fullUrl += '?' + qs.join('&');
|
|
|
|
|
+ }
|
|
|
|
|
+ } else if (method === 'POST' && params) {
|
|
|
|
|
+ var parts = [];
|
|
|
|
|
+ for (var key in params) {
|
|
|
|
|
+ if (params.hasOwnProperty(key) && params[key] !== undefined) {
|
|
|
|
|
+ parts.push(encodeURIComponent(key) + '=' + encodeURIComponent(params[key]));
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ body = parts.join('&');
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ var resp = shadowman.http.request(fullUrl, {
|
|
|
|
|
+ method: method,
|
|
|
|
|
+ body: body,
|
|
|
|
|
+ headers: headers,
|
|
|
|
|
+ contentType: contentType
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ if (resp.status === 401) {
|
|
|
|
|
+ // Token might have expired - signal to retry
|
|
|
|
|
+ return { _tokenExpired: true };
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (resp.status >= 400) {
|
|
|
|
|
+ shadowman.log.error('Zoho API error: ' + resp.status + ' ' + resp.body);
|
|
|
|
|
+ var errBody;
|
|
|
|
|
+ try {
|
|
|
|
|
+ errBody = JSON.parse(resp.body);
|
|
|
|
|
+ } catch (e) {
|
|
|
|
|
+ errBody = { error: resp.body };
|
|
|
|
|
+ }
|
|
|
|
|
+ return { error: 'Zoho API error (' + resp.status + '): ' + (errBody.description || errBody.message || errBody.error || resp.body) };
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ try {
|
|
|
|
|
+ return JSON.parse(resp.body);
|
|
|
|
|
+ } catch (e) {
|
|
|
|
|
+ return { error: 'Invalid JSON response: ' + resp.body };
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// Retry wrapper - auto-refreshes token on 401
|
|
|
|
|
+function apiCall(token, method, path, params) {
|
|
|
|
|
+ var result = makeRequest(token, method, path, params);
|
|
|
|
|
+ if (result && result._tokenExpired) {
|
|
|
|
|
+ // Token expired, trigger a refresh via auth module
|
|
|
|
|
+ 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, params);
|
|
|
|
|
+ }
|
|
|
|
|
+ return result;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function listProjects(token, index) {
|
|
|
|
|
+ var portalId = shadowman.config.value('portal_id');
|
|
|
|
|
+ if (!portalId) {
|
|
|
|
|
+ return { error: 'Missing portal_id in plugin config.' };
|
|
|
|
|
+ }
|
|
|
|
|
+ return apiCall(token, 'GET', '/restapi/portal/{PORTALID}/projects/', {
|
|
|
|
|
+ index: String(index)
|
|
|
|
|
+ });
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function listTasks(token, projectId, filters) {
|
|
|
|
|
+ var portalId = shadowman.config.value('portal_id');
|
|
|
|
|
+ if (!portalId) {
|
|
|
|
|
+ return { error: 'Missing portal_id in plugin config.' };
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ 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', '/restapi/portal/{PORTALID}/projects/{PROJECTID}/tasks/'
|
|
|
|
|
+ .replace('{PROJECTID}', projectId), params);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function getTask(token, projectId, taskId) {
|
|
|
|
|
+ var portalId = shadowman.config.value('portal_id');
|
|
|
|
|
+ if (!portalId) {
|
|
|
|
|
+ return { error: 'Missing portal_id in plugin config.' };
|
|
|
|
|
+ }
|
|
|
|
|
+ return apiCall(token, 'GET',
|
|
|
|
|
+ '/restapi/portal/{PORTALID}/projects/{PROJECTID}/tasks/{TASKID}/'
|
|
|
|
|
+ .replace('{PROJECTID}', projectId)
|
|
|
|
|
+ .replace('{TASKID}', taskId), {});
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function createTask(token, projectId, args) {
|
|
|
|
|
+ var portalId = shadowman.config.value('portal_id');
|
|
|
|
|
+ if (!portalId) {
|
|
|
|
|
+ return { error: 'Missing portal_id in plugin config.' };
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ var params = { name: args.name };
|
|
|
|
|
+ if (args.description) params.description = args.description;
|
|
|
|
|
+ if (args.person_responsible) params.person_responsible = String(args.person_responsible);
|
|
|
|
|
+ if (args.priority) params.priority = args.priority;
|
|
|
|
|
+ if (args.start_date) params.start_date = args.start_date;
|
|
|
|
|
+ if (args.end_date) params.end_date = args.end_date;
|
|
|
|
|
+ if (args.tasklist_id) params.tasklist_id = String(args.tasklist_id);
|
|
|
|
|
+ if (args.milestone_id) params.milestone_id = String(args.milestone_id);
|
|
|
|
|
+ if (args.percent_complete) params.percent_complete = String(args.percent_complete);
|
|
|
|
|
+
|
|
|
|
|
+ return apiCall(token, 'POST',
|
|
|
|
|
+ '/restapi/portal/{PORTALID}/projects/{PROJECTID}/tasks/'
|
|
|
|
|
+ .replace('{PROJECTID}', projectId), params);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function updateTask(token, projectId, taskId, args) {
|
|
|
|
|
+ var portalId = shadowman.config.value('portal_id');
|
|
|
|
|
+ if (!portalId) {
|
|
|
|
|
+ return { error: 'Missing portal_id in plugin config.' };
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ var params = {};
|
|
|
|
|
+ if (args.name) params.name = args.name;
|
|
|
|
|
+ if (args.description !== undefined) params.description = args.description;
|
|
|
|
|
+ if (args.person_responsible) params.person_responsible = String(args.person_responsible);
|
|
|
|
|
+ if (args.priority) params.priority = args.priority;
|
|
|
|
|
+ if (args.start_date) params.start_date = args.start_date;
|
|
|
|
|
+ if (args.end_date) params.end_date = args.end_date;
|
|
|
|
|
+ if (args.percent_complete !== undefined) params.percent_complete = String(args.percent_complete);
|
|
|
|
|
+
|
|
|
|
|
+ return apiCall(token, 'POST',
|
|
|
|
|
+ '/restapi/portal/{PORTALID}/projects/{PROJECTID}/tasks/{TASKID}/'
|
|
|
|
|
+ .replace('{PROJECTID}', projectId)
|
|
|
|
|
+ .replace('{TASKID}', taskId), params);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function deleteTask(token, projectId, taskId) {
|
|
|
|
|
+ var portalId = shadowman.config.value('portal_id');
|
|
|
|
|
+ if (!portalId) {
|
|
|
|
|
+ return { error: 'Missing portal_id in plugin config.' };
|
|
|
|
|
+ }
|
|
|
|
|
+ return apiCall(token, 'DELETE',
|
|
|
|
|
+ '/restapi/portal/{PORTALID}/projects/{PROJECTID}/tasks/{TASKID}/'
|
|
|
|
|
+ .replace('{PROJECTID}', projectId)
|
|
|
|
|
+ .replace('{TASKID}', taskId), {});
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function getMyTasks(token, filters) {
|
|
|
|
|
+ var portalId = shadowman.config.value('portal_id');
|
|
|
|
|
+ if (!portalId) {
|
|
|
|
|
+ return { error: 'Missing portal_id in plugin config.' };
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ 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', '/restapi/portal/{PORTALID}/mytasks/', params);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function searchTasks(token, args) {
|
|
|
|
|
+ // Zoho Projects doesn't have a dedicated search endpoint, so we search across all projects
|
|
|
|
|
+ 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) {
|
|
|
|
|
+ // Search within a specific project
|
|
|
|
|
+ var result = apiCall(token, 'GET',
|
|
|
|
|
+ '/restapi/portal/{PORTALID}/projects/{PROJECTID}/tasks/'
|
|
|
|
|
+ .replace('{PROJECTID}', projectId), {
|
|
|
|
|
+ index: '1'
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ if (result.error) return result;
|
|
|
|
|
+
|
|
|
|
|
+ var tasks = result.tasks || [];
|
|
|
|
|
+ var matched = [];
|
|
|
|
|
+ for (var i = 0; i < tasks.length; i++) {
|
|
|
|
|
+ var t = tasks[i];
|
|
|
|
|
+ if (t.name && t.name.toLowerCase().indexOf(query.toLowerCase()) !== -1) {
|
|
|
|
|
+ matched.push(t);
|
|
|
|
|
+ } else if (t.description && t.description.toLowerCase().indexOf(query.toLowerCase()) !== -1) {
|
|
|
|
|
+ matched.push(t);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return { tasks: matched, total: matched.length, query: query };
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Search across all projects - first get project list
|
|
|
|
|
+ var projects = apiCall(token, 'GET', '/restapi/portal/{PORTALID}/projects/', {});
|
|
|
|
|
+ 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',
|
|
|
|
|
+ '/restapi/portal/{PORTALID}/projects/{PROJECTID}/tasks/'
|
|
|
|
|
+ .replace('{PROJECTID}', String(proj.id)), {
|
|
|
|
|
+ index: '1'
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ if (taskResult.error) continue;
|
|
|
|
|
+
|
|
|
|
|
+ var tasks = taskResult.tasks || [];
|
|
|
|
|
+ for (var i = 0; i < tasks.length; i++) {
|
|
|
|
|
+ var t = tasks[i];
|
|
|
|
|
+ if (t.name && t.name.toLowerCase().indexOf(query.toLowerCase()) !== -1) {
|
|
|
|
|
+ t._project_name = proj.name;
|
|
|
|
|
+ allTasks.push(t);
|
|
|
|
|
+ } else if (t.description && t.description.toLowerCase().indexOf(query.toLowerCase()) !== -1) {
|
|
|
|
|
+ t._project_name = proj.name;
|
|
|
|
|
+ allTasks.push(t);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return { tasks: allTasks, total: allTasks.length, query: query };
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+module.exports = {
|
|
|
|
|
+ listProjects: listProjects,
|
|
|
|
|
+ listTasks: listTasks,
|
|
|
|
|
+ getTask: getTask,
|
|
|
|
|
+ createTask: createTask,
|
|
|
|
|
+ updateTask: updateTask,
|
|
|
|
|
+ deleteTask: deleteTask,
|
|
|
|
|
+ getMyTasks: getMyTasks,
|
|
|
|
|
+ searchTasks: searchTasks
|
|
|
|
|
+};
|