Dashboard (content library, publishing queue with approvals, inbox, team management, AI runs), Meta connector, branded PDF reports, agent skills, and Docker packaging (Dockerfile, docker-compose with Caddy HTTPS, backup and path-relocation scripts). Client data, live data and secrets are excluded via .gitignore. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
161 lines
7.4 KiB
JavaScript
161 lines
7.4 KiB
JavaScript
// 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 ? `<p>${String(description).replace(/[&<>]/g, (c) => ({ "&": "&", "<": "<", ">": ">" })[c]).replace(/\n/g, "<br>")}</p>` : false,
|
|
user_ids: [[6, 0, userIds.map(Number)]],
|
|
};
|
|
if (deadline) vals.date_deadline = `${deadline} 17:00:00`;
|
|
const id = await call("project.task", "create", [vals]);
|
|
cache.delete("projects");
|
|
return { id };
|
|
}
|
|
|
|
export async function moveTask(taskId, stageId) {
|
|
assertWritable();
|
|
await call("project.task", "write", [[Number(taskId)], { stage_id: Number(stageId) }]);
|
|
return { ok: true };
|
|
}
|
|
|
|
/** Best-effort: log a note on a task and (optionally) move it to the first stage of its project whose name matches.
|
|
* Used by the content library so Odoo tasks follow the work. Never throws; returns { ok } or { skipped/error }. */
|
|
export async function taskActivity(taskRef, { note, stageMatch } = {}) {
|
|
const id = Number(String(taskRef || "").replace(/\D/g, ""));
|
|
if (!id || !configured()) return { skipped: "no task or Odoo not configured" };
|
|
if (!ODOO.allowWrites) return { skipped: "ODOO_ALLOW_WRITES is off" };
|
|
try {
|
|
if (note) await call("project.task", "message_post", [[id]], { body: note, message_type: "comment", subtype_xmlid: "mail.mt_note" });
|
|
if (stageMatch) {
|
|
const [t] = await call("project.task", "read", [[id]], { fields: ["project_id"] });
|
|
const st = t?.project_id ? await call("project.task.type", "search_read", [[["project_ids", "in", [t.project_id[0]]], ["name", "ilike", stageMatch]]], { fields: ["id", "name"], limit: 1, order: "sequence" }) : [];
|
|
if (st[0]) await call("project.task", "write", [[id], { stage_id: st[0].id }]);
|
|
}
|
|
return { ok: true };
|
|
} catch (e) { return { error: e.message }; }
|
|
}
|