- Server settings (Settings page): SMTP, AI, publishing, Meta, platform keys and, on the control centre only, Stripe/billing. Stored sealed in data/settings.json and applied over .env in every process; hosted agency workspaces don't see our email, platform keys or billing. - Odoo connection per workspace in Connections (login checked before saving). - provision-tenants.sh marks agency containers with QUORLA_HOSTED_TENANT=true. - docs/wildcard-subdomains.html: one-time setup for automatic agency subdomains. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
198 lines
9.3 KiB
JavaScript
198 lines
9.3 KiB
JavaScript
// 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 ? `<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 writes are 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 }; }
|
|
}
|