metatroncubeswdev c309581df0 Initial commit: Quorla social media manager
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>
2026-10-19 13:02:29 -04:00

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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;" })[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 }; }
}