// Odoo (workplace.metatroncube.in) over JSON-RPC: team, projects and Project tasks. // Reads always work when configured. Creating/moving tasks needs ODOO_ALLOW_WRITES=true. import { ODOO } from "./config.js"; let uidPromise = null; const cache = new Map(); export const configured = () => Boolean(ODOO.url && ODOO.db && ODOO.username && ODOO.secret); async function rpc(service, method, args) { const res = await fetch(`${ODOO.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 Object.assign(new Error("Odoo isn't configured. Add ODOO_URL, ODOO_DATABASE, ODOO_USERNAME and ODOO_API_KEY to .env."), { status: 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 .env."); return id; }); try { return await uidPromise; } catch (e) { uidPromise = null; throw e; } } 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. Set ODOO_ALLOW_WRITES=true in .env to allow it."), { 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, "
")}