← back to AbramsOS

lib/digest.js

79 lines

// Daily deadline digest — builds an email from upcoming deadlines and sends it
// via George (self-send to Steve's own inbox, which George treats as internal,
// so no external-send approval token is needed). Safe no-op if unconfigured.

const db = require('./db');
const engine = require('./reminder-engine');

const GEORGE_URL = (process.env.GEORGE_URL || '').replace(/\/$/, '');
const GEORGE_BASIC_AUTH = process.env.GEORGE_BASIC_AUTH || '';   // base64 of "admin:pass"
const DIGEST_TO = process.env.DIGEST_TO || '';
const DIGEST_ACCOUNT = process.env.DIGEST_ACCOUNT || 'steve-office'; // steve-personal token expires weekly; office is stable
const DIGEST_DAYS = parseInt(process.env.DIGEST_DAYS || '21', 10);
const APP_URL = process.env.BASE_URL || 'http://localhost:9774';

function esc(s) {
  return String(s == null ? '' : s).replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
}

async function buildDigest(userId) {
  await engine.generateForUser(userId);
  const rows = await engine.upcomingForUser(userId, { days: DIGEST_DAYS });
  const now = Date.now();
  const items = rows.map((r) => ({ ...r, days: Math.round((new Date(r.due_at).getTime() - now) / 86400000) }));

  const overdue = items.filter((i) => i.days < 0);
  const thisWeek = items.filter((i) => i.days >= 0 && i.days <= 7);
  const later = items.filter((i) => i.days > 7);

  const subject = `AbramsOS · ${items.length} deadline${items.length === 1 ? '' : 's'}` +
    (overdue.length ? ` (${overdue.length} overdue)` : '') +
    ` · ${new Date().toLocaleDateString(undefined, { month: 'short', day: 'numeric' })}`;

  const section = (title, list, color) => {
    if (!list.length) return '';
    const li = list.map((i) => {
      const when = i.days < 0 ? `${Math.abs(i.days)}d overdue` : i.days === 0 ? 'today' : `in ${i.days}d`;
      return `<li style="margin:6px 0"><b>${esc(when)}</b> — ${esc(i.title)}${i.body ? `<br><span style="color:#667">${esc(i.body)}</span>` : ''}</li>`;
    }).join('');
    return `<h3 style="color:${color};margin:18px 0 6px">${esc(title)}</h3><ul style="padding-left:18px;margin:0">${li}</ul>`;
  };

  const html = `<div style="font-family:-apple-system,Segoe UI,Roboto,sans-serif;max-width:620px;color:#111">
    <h2 style="margin:0 0 4px">Your AbramsOS deadlines</h2>
    <p style="color:#667;margin:0 0 8px">${new Date().toLocaleDateString(undefined, { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' })} · next ${DIGEST_DAYS} days</p>
    ${items.length ? '' : '<p>Nothing due — you\'re clear. ✅</p>'}
    ${section('⚠️ Overdue', overdue, '#c0392b')}
    ${section('This week', thisWeek, '#b8860b')}
    ${section('Coming up', later, '#2c3e50')}
    <p style="margin-top:22px"><a href="${APP_URL}/deadlines" style="color:#0369a1">Open Deadlines →</a></p>
  </div>`;

  const text = [`Your AbramsOS deadlines — next ${DIGEST_DAYS} days`, '']
    .concat(items.length ? items.map((i) => `- ${i.days < 0 ? Math.abs(i.days) + 'd overdue' : 'in ' + i.days + 'd'}: ${i.title}`) : ['Nothing due — clear.'])
    .concat(['', `${APP_URL}/deadlines`]).join('\n');

  return { subject, html, text, counts: { total: items.length, overdue: overdue.length, thisWeek: thisWeek.length } };
}

async function sendDigest(userId) {
  const d = await buildDigest(userId);
  // Suppress zero-deadline days — no "nothing due" noise in the inbox.
  if (d.counts.total === 0) {
    return { sent: false, skipped: 'no deadlines — empty day suppressed', ...d.counts };
  }
  if (!GEORGE_URL || !GEORGE_BASIC_AUTH || !DIGEST_TO) {
    return { sent: false, skipped: 'george-not-configured (set GEORGE_URL, GEORGE_BASIC_AUTH, DIGEST_TO in .env)', ...d.counts };
  }
  const res = await fetch(`${GEORGE_URL}/api/send`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json', Authorization: 'Basic ' + GEORGE_BASIC_AUTH },
    body: JSON.stringify({ account: DIGEST_ACCOUNT, to: DIGEST_TO, subject: d.subject, body: d.html }),
  });
  const json = await res.json().catch(() => ({}));
  if (!res.ok || json.error) throw new Error(`George send failed (${res.status}): ${json.error || 'unknown'}`);
  return { sent: true, messageId: json.messageId, ...d.counts };
}

module.exports = { buildDigest, sendDigest };