// Odoo over JSON-RPC: team, projects and Project tasks. // Settings come from Connections → Odoo (stored encrypted in the vault), else from .env (ODOO_*). // Reads always work when configured. Creating/moving tasks needs "allow writes". import { ODOO as ENV_ODOO } from "./config.js"; import * as vault from "../../connector/src/platforms/vault.js"; let uidPromise = null; const cache = new Map(); const bad = (message, status = 400) => Object.assign(new Error(message), { status }); function load() { const v = vault.integration("odoo"); return v ? { ...v, source: "settings" } : { ...ENV_ODOO, source: ENV_ODOO.url ? "env" : null }; } let ODOO = load(); export const configured = () => Boolean(ODOO.url && ODOO.db && ODOO.username && ODOO.secret); async function rpc(service, method, args, url = ODOO.url) { const res = await fetch(`${url}/jsonrpc`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ jsonrpc: "2.0", method: "call", params: { service, method, args }, id: Date.now() }), signal: AbortSignal.timeout(20000), }); if (!res.ok) throw new Error(`Odoo HTTP ${res.status}`); const json = await res.json(); if (json.error) throw new Error(json.error.data?.message || json.error.message || "Odoo error"); return json.result; } async function uid() { if (!configured()) throw bad("Odoo isn't connected. An admin can connect it in Connections → Odoo.", 503); uidPromise ??= rpc("common", "login", [ODOO.db, ODOO.username, ODOO.secret]).then((id) => { if (!id) throw new Error("Odoo login failed — check the username and API key in Connections → Odoo."); return id; }); try { return await uidPromise; } catch (e) { uidPromise = null; throw e; } } function reset() { ODOO = load(); uidPromise = null; cache.clear(); } /** What the Connections page shows (never the key). */ export function settings() { return { configured: configured(), source: ODOO.source, url: ODOO.url, db: ODOO.db, username: ODOO.username, allowWrites: ODOO.allowWrites, updatedAt: ODOO.updatedAt || null, updatedBy: ODOO.updatedBy || null }; } /** Check the login against Odoo, then store it encrypted. Empty key keeps the stored one. */ export async function saveSettings({ url, db, username, apiKey, allowWrites }, by) { const next = { url: String(url || "").trim().replace(/\/+$/, "").replace(/\/(web|odoo)(\/.*)?$/, ""), db: String(db || "").trim(), username: String(username || "").trim(), secret: String(apiKey || "").trim() || (ODOO.source === "settings" ? ODOO.secret : ""), allowWrites: Boolean(allowWrites), }; if (!/^https?:\/\/[^\s/]+/.test(next.url)) throw bad("Enter the Odoo address, e.g. https://yourcompany.odoo.com"); if (!next.db || !next.username || !next.secret) throw bad("Database, username and API key are all required."); let id; try { id = await rpc("common", "login", [next.db, next.username, next.secret], next.url); } catch (e) { throw bad(`Couldn't reach Odoo: ${e.message}`); } if (!id) throw bad("Odoo rejected the login. Check the database name, the username (login email) and the API key."); vault.saveIntegration("odoo", next, by); reset(); return settings(); } export function removeSettings() { vault.removeIntegration("odoo"); reset(); return settings(); } async function call(model, method, args = [], kwargs = {}) { return rpc("object", "execute_kw", [ODOO.db, await uid(), ODOO.secret, model, method, args, kwargs]); } async function cached(key, ttlMs, fn) { const hit = cache.get(key); if (hit && Date.now() - hit.at < ttlMs) return hit.value; const value = await fn(); cache.set(key, { at: Date.now(), value }); return value; } export async function status() { if (!configured()) return { configured: false, connected: false, allowWrites: ODOO.allowWrites }; try { const version = await rpc("common", "version", []); await uid(); return { configured: true, connected: true, url: ODOO.url, version: version.server_version, allowWrites: ODOO.allowWrites }; } catch (e) { return { configured: true, connected: false, url: ODOO.url, error: e.message, allowWrites: ODOO.allowWrites }; } } export const members = () => cached("members", 10 * 60 * 1000, () => call("res.users", "search_read", [[["share", "=", false], ["active", "=", true]]], { fields: ["name", "login"], order: "name" }) ); export const projects = () => cached("projects", 5 * 60 * 1000, () => call("project.project", "search_read", [[["active", "=", true]]], { fields: ["name", "task_count", "user_id"], order: "name" }) ); export const stages = () => cached("stages", 30 * 60 * 1000, () => call("project.task.type", "search_read", [[]], { fields: ["name", "sequence", "fold"], order: "sequence" })); const TASK_FIELDS = ["name", "project_id", "user_ids", "stage_id", "date_deadline", "priority", "state", "tag_ids", "create_date", "write_date", "date_last_stage_update"]; /** Open tasks (closed/folded stages excluded unless includeDone). */ export async function tasks({ projectIds = [], includeDone = false } = {}) { const domain = []; if (projectIds.length) domain.push(["project_id", "in", projectIds.map(Number)]); if (!includeDone) domain.push(["stage_id.fold", "=", false]); const [rows, people] = await Promise.all([ call("project.task", "search_read", [domain], { fields: TASK_FIELDS, order: "date_deadline asc, priority desc, id desc", limit: 500 }), members(), ]); const nameOf = new Map(people.map((p) => [p.id, p.name])); // Assignees outside the active internal list (admin, archived users) still need a real name. const missing = [...new Set(rows.flatMap((t) => t.user_ids || []))].filter((id) => !nameOf.has(id)); if (missing.length) { const extra = await call("res.users", "read", [missing, ["name"]], { context: { active_test: false } }).catch(() => []); for (const u of extra) nameOf.set(u.id, u.name); } const now = Date.now(); return rows.map((t) => ({ id: t.id, name: t.name, project: t.project_id ? { id: t.project_id[0], name: t.project_id[1] } : null, stage: t.stage_id ? { id: t.stage_id[0], name: t.stage_id[1] } : null, assignees: (t.user_ids || []).map((id) => ({ id, name: nameOf.get(id) || `User ${id}` })), deadline: t.date_deadline || null, overdue: Boolean(t.date_deadline && new Date(t.date_deadline.replace(" ", "T") + "Z") < now), priority: t.priority, state: t.state, updatedAt: t.write_date, url: `${ODOO.url}/web#id=${t.id}&model=project.task&view_type=form`, })); } /** Workload per person: open tasks, overdue, due this week. */ export async function workload() { const [open, people] = await Promise.all([tasks(), members()]); const week = Date.now() + 7 * 86400 * 1000; return people .map((p) => { const mine = open.filter((t) => t.assignees.some((a) => a.id === p.id)); return { id: p.id, name: p.name, open: mine.length, overdue: mine.filter((t) => t.overdue).length, dueThisWeek: mine.filter((t) => t.deadline && !t.overdue && new Date(t.deadline.replace(" ", "T") + "Z") < week).length, }; }) .sort((a, b) => b.open - a.open || a.name.localeCompare(b.name)); } function assertWritable() { if (!ODOO.allowWrites) throw Object.assign(new Error("Task creation is turned off. Turn on “Allow creating and moving tasks” in Connections → Odoo."), { status: 403 }); } export async function createTask({ projectId, name, description = "", userIds = [], deadline = null }) { assertWritable(); if (!projectId || !String(name || "").trim()) throw Object.assign(new Error("Project and task name are required."), { status: 400 }); const vals = { project_id: Number(projectId), name: String(name).trim().slice(0, 250), description: description ? `
${String(description).replace(/[&<>]/g, (c) => ({ "&": "&", "<": "<", ">": ">" })[c]).replace(/\n/g, "
")}