Răsfoiți Sursa

refactor: migrate to v3 API, add timesheet support, fix token refresh, auto-resolve portal

Changes:
- Migrate all task/comment endpoints from v1 to v3 API (JSON body)
- Add timesheet tools: log_time, list_timesheets, delete_timesheet
- Add resolve_portal tool — auto-detects numeric portal ID from URL
- Fix token refresh bug (http.post body was not being sent)
- Fix error message display (no more [object Object])
- Fix DELETE response handling (empty body → success)
- Portal config: portal_url instead of portal_id, auto-resolved on auth
- Version bump: 1.0.0 → 2.0.0
ShadowMan Service User 2 zile în urmă
părinte
comite
662dd0274e
4 a modificat fișierele cu 483 adăugiri și 193 ștergeri
  1. 33 3
      index.js
  2. 172 17
      lib/auth.js
  3. 222 171
      lib/tasks.js
  4. 56 2
      manifest.json

+ 33 - 3
index.js

@@ -1,9 +1,9 @@
-// Zoho Tasks Plugin v1.0.0
-// EU data center: accounts.zoho.eu / projectsapi.zoho.eu
+// Zoho Tasks Plugin v2.0.0
+// EU data center: accounts.zoho.eu
 var auth = require('./lib/auth');
 var tasks = require('./lib/tasks');
 
-shadowman.log.info('Zoho Tasks plugin v1.0.0 loaded');
+shadowman.log.info('Zoho Tasks plugin v2.0.0 loaded');
 
 // Auth tools
 shadowman.tools.register('setup_auth', function(args, context) {
@@ -15,6 +15,12 @@ shadowman.tools.register('auth_status', function(args, context) {
     return auth.getStatus();
 });
 
+shadowman.tools.register('resolve_portal', function(args, context) {
+    var token = auth.getValidToken();
+    if (!token) return { error: 'Not authenticated. Run setup_auth with a grant token first.' };
+    return auth.resolvePortal(token, args);
+});
+
 // Project tools
 shadowman.tools.register('list_projects', function(args, context) {
     var token = auth.getValidToken();
@@ -103,3 +109,27 @@ shadowman.tools.register('delete_comment', function(args, context) {
     if (!args.comment_id) return { error: 'Missing comment_id parameter' };
     return tasks.deleteComment(token, args.project_id, args.task_id, args.comment_id);
 });
+
+shadowman.tools.register('log_time', function(args, context) {
+    var token = auth.getValidToken();
+    if (!token) return { error: 'Not authenticated. Run setup_auth with a grant token first.' };
+    if (!args.project_id) return { error: 'Missing project_id parameter' };
+    if (!args.task_id) return { error: 'Missing task_id parameter' };
+    if (args.hours === undefined) return { error: 'Missing hours parameter' };
+    return tasks.logTime(token, args.project_id, args);
+});
+
+shadowman.tools.register('list_timesheets', function(args, context) {
+    var token = auth.getValidToken();
+    if (!token) return { error: 'Not authenticated. Run setup_auth with a grant token first.' };
+    if (!args.project_id) return { error: 'Missing project_id parameter' };
+    return tasks.listTimesheets(token, args.project_id, args);
+});
+
+shadowman.tools.register('delete_timesheet', function(args, context) {
+    var token = auth.getValidToken();
+    if (!token) return { error: 'Not authenticated. Run setup_auth with a grant token first.' };
+    if (!args.project_id) return { error: 'Missing project_id parameter' };
+    if (!args.log_id) return { error: 'Missing log_id parameter' };
+    return tasks.deleteTimesheet(token, args.project_id, args.log_id);
+});

+ 172 - 17
lib/auth.js

@@ -2,7 +2,7 @@
 // EU data center: accounts.zoho.eu
 
 var ACCOUNTS_BASE = 'https://accounts.zoho.eu';
-var SCOPE = 'ZohoProjects.tasks.ALL,ZohoProjects.projects.READ,ZohoProjects.tasklists.READ';
+var SCOPE = 'ZohoProjects.tasks.ALL,ZohoProjects.projects.READ,ZohoProjects.tasklists.READ,ZohoProjects.timesheets.ALL,ZohoProjects.statuses.READ,ZohoProjects.tasklists.CREATE,ZohoProjects.tasklists.UPDATE,ZohoProjects.portals.READ';
 
 function setupAuth(grantToken) {
     var clientId = shadowman.config.value('client_id');
@@ -18,8 +18,6 @@ function setupAuth(grantToken) {
 
     shadowman.log.info('Exchanging Zoho grant token for access + refresh tokens...');
 
-    // Exchange grant token for access + refresh tokens
-    // POST /oauth/v2/token with grant_type=authorization_code
     var body = 'grant_type=authorization_code' +
                '&client_id=' + encodeURIComponent(clientId) +
                '&client_secret=' + encodeURIComponent(clientSecret) +
@@ -50,7 +48,6 @@ function setupAuth(grantToken) {
         return { error: 'Invalid response from Zoho: ' + resp.body };
     }
 
-    // Store tokens with expiry calculation
     shadowman.storage.set('tokens', {
         access_token: tokens.access_token,
         refresh_token: tokens.refresh_token,
@@ -61,21 +58,173 @@ function setupAuth(grantToken) {
 
     shadowman.log.info('Zoho OAuth tokens stored successfully. Refresh token does not expire.');
 
+    // Auto-resolve portal if portal_url config is set
+    var portalUrl = shadowman.config.value('portal_url');
+    var resolveResult = null;
+    if (portalUrl) {
+        resolveResult = resolvePortalInternal(tokens.access_token, portalUrl, null);
+    }
+
+    var result = {
+        success: true,
+        message: 'Authentication successful. Refresh token stored permanently.',
+        expires_in: tokens.expires_in
+    };
+
+    if (resolveResult && resolveResult.success) {
+        result.portal_id = resolveResult.portal_id;
+        result.portal_name = resolveResult.portal_name;
+        result.message += ' Portal auto-resolved: ' + resolveResult.portal_name + ' (ID: ' + resolveResult.portal_id + ')';
+    } else if (portalUrl) {
+        result.message += ' Portal auto-resolution failed. Run resolve_portal manually.';
+    } else {
+        result.message += ' Run resolve_portal with your portal URL to auto-configure.';
+    }
+
     // Notify conversation
     var convId = shadowman.storage.get('auth_conversation_id');
     if (convId) {
+        var msg = 'Zoho OAuth sikeres!';
+        if (resolveResult && resolveResult.success) {
+            msg += ' Portal automatikusan beállítva: ' + resolveResult.portal_name;
+        } else {
+            msg += ' Futtasd a resolve_portal parancsot a portal URL-eddel.';
+        }
         shadowman.events.emit('message', {
-            text: 'Zoho OAuth sikeres! A refresh token nem jár le, az access token automatikusan frissül. Most már használhatod a Zoho Tasks funkciókat.',
+            text: msg,
             conversationId: convId
         });
         shadowman.storage.del('auth_conversation_id');
     }
 
-    return {
-        success: true,
-        message: 'Authentication successful. Refresh token stored permanently.',
-        expires_in: tokens.expires_in
-    };
+    return result;
+}
+
+function resolvePortalInternal(token, portalUrl, portalName) {
+    var apiBase = 'https://projectsapi.zoho.eu';
+
+    // Method 1: /api/v3/portals — returns all portals with numeric ID, name, slug
+    var resp = shadowman.http.request(apiBase + '/api/v3/portals', {
+        method: 'GET',
+        headers: {
+            'Authorization': 'Zoho-oauthtoken ' + token
+        }
+    });
+
+    if (resp.status === 200) return processPortalsResponse(resp.body, portalUrl, portalName);
+
+    // Method 2: Fallback — use v1 API with slug, extract numeric ID from response
+    var slug = portalUrl ? extractSlug(portalUrl) : '';
+    if (!slug) {
+        var existingId = shadowman.config.value('portal_id');
+        if (existingId) {
+            return { success: true, portal_id: existingId, note: 'Using existing portal_id. Re-run setup_auth with ZohoProjects.portals.READ scope to enable /api/v3/portals.' };
+        }
+        return { error: 'Portal URL not provided and /api/v3/portals failed (scope?). Run setup_auth again with a fresh grant token.' };
+    }
+
+    var testResp = shadowman.http.request(apiBase + '/restapi/portal/' + slug + '/projects/', {
+        method: 'GET',
+        headers: {
+            'Authorization': 'Zoho-oauthtoken ' + token
+        }
+    });
+
+    if (testResp.status === 200) {
+        try {
+            var testData = JSON.parse(testResp.body);
+            var projects = testData.projects || [];
+            if (projects.length > 0) {
+                var url = (projects[0].link || {}).self || {};
+                var urlStr = url.url || '';
+                var urlMatch = urlStr.match(/\/portal\/(\d+)\//);
+                if (urlMatch) {
+                    var numericId = urlMatch[1];
+                    shadowman.config.set('portal_id', numericId);
+                    shadowman.log.info('Portal resolved (v1 fallback): ' + slug + ' -> ID: ' + numericId);
+                    return { success: true, portal_id: numericId, portal_slug: slug };
+                }
+            }
+        } catch (e) { /* ignore */ }
+
+        shadowman.config.set('portal_id', slug);
+        shadowman.log.info('Portal configured with slug (no numeric ID found): ' + slug);
+        return { success: true, portal_id: slug, portal_slug: slug, note: 'Using slug. Timesheet API may require numeric ID.' };
+    }
+
+    return { error: 'Portal "' + slug + '" not found. Check the URL and your access.' };
+}
+
+function processPortalsResponse(body, portalUrl, portalName) {
+    var data;
+    try {
+        data = JSON.parse(body);
+    } catch (e) {
+        return { error: 'Invalid portal list response: ' + body };
+    }
+
+    var portals = data.portals || [];
+    if (portals.length === 0) {
+        return { error: 'No portals found for this account.', portals: [] };
+    }
+
+    if (portals.length === 1 && !portalUrl && !portalName) {
+        var p = portals[0];
+        shadowman.config.set('portal_id', String(p.id));
+        shadowman.log.info('Portal auto-configured: ' + p.name + ' (ID: ' + p.id + ')');
+        return { success: true, portal_id: String(p.id), portal_name: p.name, portal_slug: p.portal_slug };
+    }
+
+    var match = null;
+    if (portalUrl) {
+        var slug = extractSlug(portalUrl);
+        for (var i = 0; i < portals.length; i++) {
+            if (portals[i].portal_slug && portals[i].portal_slug.toLowerCase() === slug.toLowerCase()) {
+                match = portals[i];
+                break;
+            }
+            if (portals[i].name && portals[i].name.toLowerCase() === slug.toLowerCase()) {
+                match = portals[i];
+                break;
+            }
+        }
+    } else if (portalName) {
+        for (var j = 0; j < portals.length; j++) {
+            if (portals[j].name && portals[j].name.toLowerCase() === portalName.toLowerCase()) {
+                match = portals[j];
+                break;
+            }
+        }
+    }
+
+    if (match) {
+        shadowman.config.set('portal_id', String(match.id));
+        shadowman.log.info('Portal configured: ' + match.name + ' (ID: ' + match.id + ')');
+        return { success: true, portal_id: String(match.id), portal_name: match.name, portal_slug: match.portal_slug };
+    }
+
+    var list = [];
+    for (var k = 0; k < portals.length; k++) {
+        list.push({
+            id: String(portals[k].id),
+            name: portals[k].name,
+            slug: portals[k].portal_slug
+        });
+    }
+    return { error: 'Portal not found. Available portals:', portals: list };
+}
+
+function extractSlug(url) {
+    if (!url) return '';
+    var slug = url;
+    var slashIdx = slug.lastIndexOf('/');
+    if (slashIdx !== -1) slug = slug.substring(slashIdx + 1);
+    if (slug === 'portal' || slug === '') return '';
+    return slug;
+}
+
+function resolvePortal(token, args) {
+    return resolvePortalInternal(token, args.portal_url || null, args.portal_name || null);
 }
 
 function getStatus() {
@@ -113,6 +262,10 @@ function getValidToken() {
         var newTokens = refreshTokenInternal();
         if (newTokens.error) {
             shadowman.log.error('Token refresh failed: ' + newTokens.error);
+            // Refresh failed but token might still be valid
+            if (Date.now() < tokens.expires_at) {
+                return tokens.access_token;
+            }
             return null;
         }
         return newTokens.access_token;
@@ -136,14 +289,15 @@ function refreshTokenInternal() {
 
     shadowman.log.info('Refreshing Zoho access token...');
 
-    var resp = shadowman.http.post(ACCOUNTS_BASE + '/oauth/v2/token', null, {
+    var body = 'grant_type=refresh_token' +
+               '&client_id=' + encodeURIComponent(clientId) +
+               '&client_secret=' + encodeURIComponent(clientSecret) +
+               '&refresh_token=' + encodeURIComponent(tokens.refresh_token);
+
+    var resp = shadowman.http.post(ACCOUNTS_BASE + '/oauth/v2/token', body, {
         headers: {
             'Content-Type': 'application/x-www-form-urlencoded'
-        },
-        body: 'grant_type=refresh_token' +
-              '&client_id=' + encodeURIComponent(clientId) +
-              '&client_secret=' + encodeURIComponent(clientSecret) +
-              '&refresh_token=' + encodeURIComponent(tokens.refresh_token)
+        }
     });
 
     if (resp.status !== 200) {
@@ -175,5 +329,6 @@ function refreshTokenInternal() {
 module.exports = {
     setupAuth: setupAuth,
     getStatus: getStatus,
-    getValidToken: getValidToken
+    getValidToken: getValidToken,
+    resolvePortal: resolvePortal
 };

+ 222 - 171
lib/tasks.js

@@ -1,72 +1,72 @@
-// Zoho Projects Tasks API Module
-// EU data center base: https://projectsapi.zoho.eu
+// 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://projectsapi.zoho.eu';
+var API_BASE = 'https://projects.zoho.eu';
 
-function makeRequest(token, method, path, params) {
+function makeRequest(token, method, path, jsonBody, queryParams) {
     var portalId = shadowman.config.value('portal_id');
     if (!portalId) {
-        return null; // Let the caller handle missing portal ID
+        return { error: 'Missing portal_id in plugin config. Run resolve_portal first.' };
     }
 
-    // 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';
+    var url = API_BASE + path.replace('{PORTALID}', portalId);
 
-    // 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) {
+    if (queryParams) {
         var qs = [];
-        for (var key in params) {
-            if (params.hasOwnProperty(key) && params[key] !== undefined) {
-                qs.push(encodeURIComponent(key) + '=' + encodeURIComponent(params[key]));
+        for (var key in queryParams) {
+            if (queryParams.hasOwnProperty(key) && queryParams[key] !== undefined) {
+                qs.push(encodeURIComponent(key) + '=' + encodeURIComponent(queryParams[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]));
-            }
+            url += '?' + qs.join('&');
         }
-        body = parts.join('&');
     }
 
-    var resp = shadowman.http.request(fullUrl, {
+    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: body,
+        body: hasBody ? JSON.stringify(jsonBody) : null,
         headers: headers,
-        contentType: contentType
+        contentType: hasBody ? 'application/json' : null
     });
 
     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 };
+        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 + '): ' + (errBody.description || errBody.message || errBody.error || resp.body) };
+        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 {
@@ -76,37 +76,47 @@ function makeRequest(token, method, path, params) {
     }
 }
 
-// Retry wrapper - auto-refreshes token on 401
-function apiCall(token, method, path, params) {
-    var result = makeRequest(token, method, path, params);
+function apiCall(token, method, path, jsonBody, queryParams) {
+    var result = makeRequest(token, method, path, jsonBody, queryParams);
     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 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.' };
+        return { error: 'Missing portal_id in plugin config. Run resolve_portal first.' };
     }
-    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 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;
@@ -115,187 +125,225 @@ function listTasks(token, projectId, filters) {
     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);
+    return apiCall(token, 'GET',
+        '/api/v3/portal/{PORTALID}/projects/{PROJECTID}/tasks'
+        .replace('{PROJECTID}', projectId), null, 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}/'
+        '/api/v3/portal/{PORTALID}/projects/{PROJECTID}/tasks/{TASKID}'
         .replace('{PROJECTID}', projectId)
-        .replace('{TASKID}', taskId), {});
+        .replace('{TASKID}', taskId), null, {});
 }
 
 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);
+    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',
-        '/restapi/portal/{PORTALID}/projects/{PROJECTID}/tasks/'
-        .replace('{PROJECTID}', projectId), params);
+        '/api/v3/portal/{PORTALID}/projects/{PROJECTID}/tasks'
+        .replace('{PROJECTID}', projectId), body);
 }
 
 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}/'
+    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), params);
+        .replace('{TASKID}', taskId), body);
 }
 
 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}/'
+        '/api/v3/portal/{PORTALID}/projects/{PROJECTID}/tasks/{TASKID}'
         .replace('{PROJECTID}', projectId)
-        .replace('{TASKID}', taskId), {});
+        .replace('{TASKID}', taskId), null, {});
 }
 
-function getMyTasks(token, filters) {
-    var portalId = shadowman.config.value('portal_id');
-    if (!portalId) {
-        return { error: 'Missing portal_id in plugin config.' };
-    }
+// --- 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', '/restapi/portal/{PORTALID}/mytasks/', params);
+    return apiCall(token, 'GET', '/api/v3/portal/{PORTALID}/mytasks', null, params);
 }
 
+// --- Search ---
+
 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.' };
-    }
+    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'
-            });
-
+            '/api/v3/portal/{PORTALID}/projects/{PROJECTID}/tasks'
+            .replace('{PROJECTID}', projectId), null, { 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 };
+        return filterTasks(result.tasks || [], query);
     }
 
-    // Search across all projects - first get project list
-    var projects = apiCall(token, 'GET', '/restapi/portal/{PORTALID}/projects/', {});
+    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',
-            '/restapi/portal/{PORTALID}/projects/{PROJECTID}/tasks/'
-            .replace('{PROJECTID}', String(proj.id)), {
-                index: '1'
-            });
-
+            '/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 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);
-            }
+        for (var j = 0; j < tasks.length; j++) {
+            tasks[j]._project_name = proj.name;
         }
+        allTasks = allTasks.concat(tasks);
     }
+    return filterTasks(allTasks, query);
+}
 
-    return { tasks: allTasks, total: allTasks.length, query: 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) {
-    var portalId = shadowman.config.value('portal_id');
-    if (!portalId) {
-        return { error: 'Missing portal_id in plugin config.' };
-    }
     return apiCall(token, 'POST',
-        '/restapi/portal/{PORTALID}/projects/{PROJECTID}/tasks/{TASKID}/comments/'
+        '/api/v3/portal/{PORTALID}/projects/{PROJECTID}/tasks/{TASKID}/comments'
         .replace('{PROJECTID}', projectId)
         .replace('{TASKID}', taskId), { content: content });
 }
 
 function updateComment(token, projectId, taskId, commentId, content) {
-    var portalId = shadowman.config.value('portal_id');
-    if (!portalId) {
-        return { error: 'Missing portal_id in plugin config.' };
-    }
-    return apiCall(token, 'POST',
-        '/restapi/portal/{PORTALID}/projects/{PROJECTID}/tasks/{TASKID}/comments/{COMMENTID}/'
+    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) {
-    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}/comments/{COMMENTID}/'
+        '/api/v3/portal/{PORTALID}/projects/{PROJECTID}/tasks/{TASKID}/comments/{COMMENTID}'
         .replace('{PROJECTID}', projectId)
         .replace('{TASKID}', taskId)
-        .replace('{COMMENTID}', commentId), {});
+        .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) {
+    // v3 API DELETE — requires JSON body: {module, from_page}
+    // NOTE: If this fails with INVALID_INPUTSTREAM, the SDK may not send DELETE body correctly.
+    var portalId = shadowman.config.value('portal_id');
+    var url = API_BASE + '/api/v3/portal/' + portalId + '/projects/' + projectId + '/logs/' + String(logId);
+    var body = JSON.stringify({ module: 'task', from_page: 'timesheetdetails' });
+    var headers = {
+        'Authorization': 'Zoho-oauthtoken ' + token,
+        'Content-Type': 'application/json'
+    };
+
+    var resp = shadowman.http.request(url, {
+        method: 'DELETE',
+        body: body,
+        headers: headers,
+        contentType: 'application/json'
+    });
+
+    if (resp.status === 401) {
+        var auth = require('./auth');
+        var newToken = auth.getValidToken();
+        if (!newToken) return { error: 'Auth expired and refresh failed.' };
+        headers['Authorization'] = 'Zoho-oauthtoken ' + newToken;
+        resp = shadowman.http.request(url, {
+            method: 'DELETE',
+            body: body,
+            headers: headers,
+            contentType: 'application/json'
+        });
+    }
+
+    if (resp.status >= 400) {
+        var msg = '';
+        if (resp.body && typeof resp.body === 'object') {
+            msg = (resp.body.error && resp.body.error.message) || JSON.stringify(resp.body);
+        } else {
+            msg = String(resp.body);
+        }
+        return { error: 'Zoho API error (' + resp.status + '): ' + msg };
+    }
+
+    return { success: true };
 }
 
 module.exports = {
@@ -309,5 +357,8 @@ module.exports = {
     searchTasks: searchTasks,
     addComment: addComment,
     updateComment: updateComment,
-    deleteComment: deleteComment
+    deleteComment: deleteComment,
+    logTime: logTime,
+    listTimesheets: listTimesheets,
+    deleteTimesheet: deleteTimesheet
 };

+ 56 - 2
manifest.json

@@ -2,7 +2,7 @@
   "id": "zoho-tasks",
   "name": "Zoho Tasks",
   "description": "Zoho Projects task management integration for EU data center",
-  "version": "1.0.0",
+  "version": "2.0.0",
   "author": "Ferenc Szontagh & Zoë",
   "sdkVersion": 1,
   "entry": "index.js",
@@ -14,7 +14,7 @@
   "config": [
     { "key": "client_id", "label": "Zoho OAuth Client ID", "required": true, "secret": true },
     { "key": "client_secret", "label": "Zoho OAuth Client Secret", "required": true, "secret": true },
-    { "key": "portal_id", "label": "Zoho Projects Portal ID", "required": true, "secret": false }
+    { "key": "portal_url", "label": "Zoho Projects Portal URL (e.g. https://projects.zoho.eu/portal/myportal)", "required": false, "secret": false }
   ],
   "tools": [
     {
@@ -28,6 +28,17 @@
         "required": ["grant_token"]
       }
     },
+    {
+      "name": "resolve_portal",
+      "description": "Resolve a portal URL or name to its numeric portal ID. Call this after setup_auth to auto-configure the portal. Optionally pass portal_url or portal_name to match a specific portal.",
+      "parameters": {
+        "type": "object",
+        "properties": {
+          "portal_url": { "type": "string", "description": "Full portal URL (e.g. https://projects.zoho.eu/portal/aicaller) or portal name slug" },
+          "portal_name": { "type": "string", "description": "Portal display name to match" }
+        }
+      }
+    },
     {
       "name": "auth_status",
       "description": "Check authentication status and token validity",
@@ -182,6 +193,49 @@
         },
         "required": ["query"]
       }
+    },
+    {
+      "name": "log_time",
+      "description": "Log time (timesheet entry) against a task in Zoho Projects",
+      "parameters": {
+        "type": "object",
+        "properties": {
+          "project_id": { "type": "string", "description": "Zoho Projects project ID" },
+          "task_id": { "type": "string", "description": "Task ID to log time against" },
+          "hours": { "type": "number", "description": "Hours worked (can include decimals, e.g. 1.5 for 1h 30m)" },
+          "minutes": { "type": "number", "description": "Additional minutes (optional)" },
+          "date": { "type": "string", "description": "Date of work (MM-DD-YYYY format, default: today)" },
+          "notes": { "type": "string", "description": "Description of the work done" }
+        },
+        "required": ["project_id", "task_id", "hours"]
+      }
+    },
+    {
+      "name": "list_timesheets",
+      "description": "List timesheet entries for a project, optionally filtered by task and date range",
+      "parameters": {
+        "type": "object",
+        "properties": {
+          "project_id": { "type": "string", "description": "Zoho Projects project ID" },
+          "task_id": { "type": "string", "description": "Optional: filter by task ID" },
+          "from_date": { "type": "string", "description": "Start date (MM-DD-YYYY)" },
+          "to_date": { "type": "string", "description": "End date (MM-DD-YYYY)" },
+          "index": { "type": "number", "description": "Page index (default 1)" }
+        },
+        "required": ["project_id"]
+      }
+    },
+    {
+      "name": "delete_timesheet",
+      "description": "Delete a timesheet entry from a Zoho Projects project",
+      "parameters": {
+        "type": "object",
+        "properties": {
+          "project_id": { "type": "string", "description": "Zoho Projects project ID" },
+          "log_id": { "type": "string", "description": "Timesheet log ID to delete" }
+        },
+        "required": ["project_id", "log_id"]
+      }
     }
   ],
   "skills": ["zoho-tasks"]