// 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, AGENCY_NAME } from "./config.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); } 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, }); return transport; } const fromAddress = () => process.env.SMTP_FROM || process.env.SMTP_USER || ""; 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 `

${esc(heading)}

${lines.map((l) => `

${esc(l)}

`).join("")} ${button ? `

${esc(button.label)}

` : ""}

${esc(footer || AGENCY_NAME)}

`; } /** 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 }) { 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 || AGENCY_NAME, address: fromAddress() }, to, subject, replyTo, text: [heading, "", ...lines, ...(button ? ["", `${button.label}: ${button.url}`] : [])].join("\n"), html: html({ heading, lines, button, footer }), }); 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); }