- 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>
113 lines
6.3 KiB
JavaScript
113 lines
6.3 KiB
JavaScript
// Notifications: an in-app list for the team (bell in the sidebar) plus email when SMTP is set up.
|
|
// Team emails go to people who keep email on in Settings; client emails (posts waiting for their approval)
|
|
// go to the reviewer saved on the client's approval link, sent in the agency's name.
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import crypto from "node:crypto";
|
|
import nodemailer from "nodemailer";
|
|
import { DATA_DIR } from "./config.js";
|
|
import { agencyName } from "./workspace.js";
|
|
import * as users from "./users.js";
|
|
|
|
const FILE = path.join(DATA_DIR, "notifications.json");
|
|
const PREFS = path.join(DATA_DIR, "notify-prefs.json");
|
|
const KEEP = 500;
|
|
const readJson = (f, d) => { try { return JSON.parse(fs.readFileSync(f, "utf8")); } catch { return d; } };
|
|
const writeJson = (f, v) => { const tmp = f + ".tmp"; fs.writeFileSync(tmp, JSON.stringify(v)); fs.renameSync(tmp, f); };
|
|
|
|
// ---------- email ----------
|
|
let transport = null;
|
|
export function emailConfigured() { return Boolean(process.env.SMTP_HOST); }
|
|
/** Drop the cached connection after SMTP settings change. */
|
|
export function resetMailer() { transport = null; }
|
|
function mailer() {
|
|
if (!emailConfigured()) return null;
|
|
transport ||= nodemailer.createTransport({
|
|
host: process.env.SMTP_HOST, port: Number(process.env.SMTP_PORT || 587), secure: Number(process.env.SMTP_PORT) === 465,
|
|
auth: process.env.SMTP_USER ? { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS || "" } : undefined,
|
|
connectionTimeout: 15000, greetingTimeout: 15000, socketTimeout: 30000, // fail with a reason instead of hanging for minutes
|
|
});
|
|
return transport;
|
|
}
|
|
// SMTP_FROM may be "support@x.com" or "Quorla <support@x.com>"; nodemailer needs the bare address here.
|
|
const fromAddress = () => { const f = process.env.SMTP_FROM || process.env.SMTP_USER || ""; return (f.match(/<([^>]+)>/)?.[1] || f).trim(); };
|
|
export const appUrl = (p = "") => `${(process.env.PUBLIC_URL || (process.env.APP_DOMAIN ? `https://${process.env.APP_DOMAIN}` : "http://localhost:4310")).replace(/\/+$/, "")}${p}`;
|
|
|
|
const esc = (s) => String(s ?? "").replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c]);
|
|
function html({ heading, lines, button, footer }) {
|
|
return `<!doctype html><html><body style="margin:0;background:#f5f6f8;font-family:Inter,Segoe UI,Arial,sans-serif;color:#1f2024">
|
|
<div style="max-width:560px;margin:0 auto;padding:28px 16px"><div style="background:#fff;border:1px solid #e4e7ec;border-radius:12px;padding:24px">
|
|
<h1 style="font-size:19px;margin:0 0 12px;color:#242142">${esc(heading)}</h1>
|
|
${lines.map((l) => `<p style="font-size:14.5px;line-height:1.55;margin:0 0 10px">${esc(l)}</p>`).join("")}
|
|
${button ? `<p style="margin:18px 0 4px"><a href="${esc(button.url)}" style="display:inline-block;background:#242142;color:#fff;text-decoration:none;font-weight:600;padding:11px 18px;border-radius:8px">${esc(button.label)}</a></p>` : ""}
|
|
</div><p style="font-size:12px;color:#6b6e78;text-align:center;margin-top:14px">${esc(footer || agencyName())}</p></div></body></html>`;
|
|
}
|
|
|
|
/** Send one email. Returns { sent } or { sent:false, reason }. Never throws (notifications mustn't break work). */
|
|
export async function sendEmail({ to, subject, heading, lines, button, footer, fromName, replyTo, attachments }) {
|
|
const m = mailer();
|
|
if (!m) return { sent: false, reason: "Email isn't set up (SMTP_HOST in .env)." };
|
|
if (!to) return { sent: false, reason: "No recipient." };
|
|
try {
|
|
await m.sendMail({
|
|
from: { name: fromName || agencyName(), address: fromAddress() }, to, subject, replyTo,
|
|
text: [heading, "", ...lines, ...(button ? ["", `${button.label}: ${button.url}`] : [])].join("\n"),
|
|
html: html({ heading, lines, button, footer }),
|
|
attachments,
|
|
});
|
|
return { sent: true };
|
|
} catch (e) {
|
|
console.error("Email failed:", e.message);
|
|
return { sent: false, reason: e.message };
|
|
}
|
|
}
|
|
|
|
// ---------- preferences ----------
|
|
const DEFAULT_PREFS = { email: true, digest: true };
|
|
export function prefsFor(userId) { return { ...DEFAULT_PREFS, ...(readJson(PREFS, {})[userId] || {}) }; }
|
|
export function setPrefs(userId, patch) {
|
|
const all = readJson(PREFS, {});
|
|
all[userId] = { ...prefsFor(userId), ...Object.fromEntries(Object.entries(patch).filter(([k]) => k in DEFAULT_PREFS).map(([k, v]) => [k, Boolean(v)])) };
|
|
writeJson(PREFS, all);
|
|
return all[userId];
|
|
}
|
|
|
|
// ---------- in-app list ----------
|
|
const activeUsers = () => users.listUsers().filter((u) => u.active);
|
|
function recipients(to) {
|
|
const all = activeUsers();
|
|
if (to === "admins") return all.filter((u) => u.role === "admin");
|
|
if (to === "all") return all;
|
|
const want = new Set((to || []).map((x) => String(x).toLowerCase()));
|
|
return all.filter((u) => (want.has("admins") && u.role === "admin") || want.has(u.id) || want.has(String(u.email).toLowerCase()) || want.has(String(u.name || "").toLowerCase()));
|
|
}
|
|
|
|
/**
|
|
* Record a notification for some team members and email those who want email.
|
|
* to: "admins" | "all" | [user ids / emails / names]. level: info | ok | warn | bad.
|
|
*/
|
|
export async function notify({ type, title, body = "", link = "", client = null, to = "admins", level = "info", email = true }) {
|
|
const people = recipients(to);
|
|
if (!people.length) return;
|
|
const n = { id: crypto.randomUUID().slice(0, 10), at: new Date().toISOString(), type, title, body, link, client, level, for: people.map((u) => u.id), readBy: [] };
|
|
const list = readJson(FILE, []);
|
|
list.push(n);
|
|
writeJson(FILE, list.slice(-KEEP));
|
|
if (!email || !emailConfigured()) return;
|
|
for (const u of people) {
|
|
if (!prefsFor(u.id).email || !u.email) continue;
|
|
await sendEmail({ to: u.email, subject: title, heading: title, lines: body ? [body] : [], button: link ? { label: "Open in Quorla", url: appUrl(link) } : null, fromName: "Quorla" });
|
|
}
|
|
}
|
|
|
|
export function listFor(userId, { limit = 50 } = {}) {
|
|
const mine = readJson(FILE, []).filter((n) => n.for.includes(userId)).reverse();
|
|
return { unread: mine.filter((n) => !n.readBy.includes(userId)).length, items: mine.slice(0, limit).map(({ for: _f, readBy, ...n }) => ({ ...n, read: readBy.includes(userId) })) };
|
|
}
|
|
|
|
export function markRead(userId, ids) {
|
|
const list = readJson(FILE, []);
|
|
for (const n of list) if (n.for.includes(userId) && (!ids || ids.includes(n.id)) && !n.readBy.includes(userId)) n.readBy.push(userId);
|
|
writeJson(FILE, list);
|
|
}
|