metatroncubeswdev 7c596ec6d5 Settings on the site instead of .env; Odoo in Connections
- 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>
2026-10-25 19:49:15 -04:00

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) => ({ "&": "&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 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 }; }
}