← back to AbramsOS

lib/claims-alert.js

118 lines

// lib/claims-alert.js — "never let a claim lapse" deadline alert.
//
// Ranks the auto-staged claims (fill_state queued / prefilled_awaiting_submit) by
// deadline then payout and emails Steve a one-glance digest with the direct claim
// link + the values already prefilled — so his ONLY remaining step (attest + submit,
// which is legally his) is a 30-second click, surfaced well before the deadline.
//
// Self-send to Steve's own inbox via George (internal — no external-send token).
// Throttled: at most once per calendar day, PLUS an immediate escalation the first
// time a claim crosses inside 3 days.  NEVER submits anything.

const fs = require('fs');
const path = require('path');
const db = require('./db');

const GEORGE_URL = (process.env.GEORGE_URL || '').replace(/\/$/, '');
const GEORGE_BASIC_AUTH = process.env.GEORGE_BASIC_AUTH || '';
const DIGEST_TO = process.env.DIGEST_TO || '';
const DIGEST_ACCOUNT = process.env.DIGEST_ACCOUNT || 'steve-office';
const APP_URL = process.env.BASE_URL || 'http://localhost:9931';
const USER = process.env.ABRAMSOS_USER_ID || 'user_steve';
const STATE = path.join(__dirname, '..', 'data', 'claims-alert-state.json');
const URGENT_DAYS = 3;

function esc(s) {
  return String(s == null ? '' : s).replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
}
function today() { return new Date().toISOString().slice(0, 10); }
// pg returns DATE columns as JS Date objects (not strings); handle both, and
// compare CALENDAR days at local midnight so 0 = due today, 1 = tomorrow.
function daysTo(d) {
  if (d == null) return null;
  const dt = d instanceof Date ? d : new Date(String(d) + 'T00:00:00');
  if (isNaN(dt.getTime())) return null;
  const target = new Date(dt.getFullYear(), dt.getMonth(), dt.getDate()).getTime();
  const now = new Date();
  const todayMid = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
  return Math.round((target - todayMid) / 86400000);
}

async function collect(userId = USER) {
  const { rows } = await db.query(
    `SELECT id,name,payout_text,payout_max_cents,deadline,mode_url,fill_state
       FROM settlement_claim
      WHERE user_id=$1
        AND fill_state IN ('queued','prefilled_awaiting_submit')
        AND (deadline IS NULL OR deadline >= current_date)
      ORDER BY deadline NULLS LAST, payout_max_cents DESC NULLS LAST`,
    [userId]
  );
  return rows.map((r) => ({ ...r, days: daysTo(r.deadline) }));
}

function render(items) {
  const urgent = items.filter((i) => i.days != null && i.days <= URGENT_DAYS);
  const soon = items.filter((i) => i.days != null && i.days > URGENT_DAYS && i.days <= 10);
  const later = items.filter((i) => i.days == null || i.days > 10);

  const subject =
    `AbramsOS Claims · ${items.length} ready to submit` +
    (urgent.length ? ` · ⏰ ${urgent.length} lapsing ≤${URGENT_DAYS}d` : '') +
    ` · ${new Date().toLocaleDateString(undefined, { month: 'short', day: 'numeric' })}`;

  const row = (i) => {
    const when = i.days == null ? 'no deadline' : i.days <= 0 ? 'DUE TODAY' : `in ${i.days}d`;
    const ready = i.fill_state === 'prefilled_awaiting_submit' ? '✅ prefilled — review & submit' : '📝 form ready to fill';
    const link = i.mode_url ? ` — <a href="${esc(i.mode_url)}">open claim ↗</a>` : '';
    return `<li style="margin:7px 0"><b>${esc(when)}</b> · ${esc(i.payout_text || '')} — ${esc(i.name)}<br>
      <span style="color:#667">${ready}${link}</span></li>`;
  };
  const section = (title, list, color) =>
    !list.length ? '' : `<h3 style="color:${color};margin:16px 0 4px">${esc(title)}</h3><ul style="padding-left:18px;margin:0">${list.map(row).join('')}</ul>`;

  const html = `<div style="font-family:-apple-system,Segoe UI,Roboto,sans-serif;max-width:640px;color:#111">
    <h2 style="margin:0 0 4px">Settlement claims — action before they lapse</h2>
    <p style="color:#667;margin:0 0 10px">${new Date().toLocaleDateString(undefined, { weekday: 'long', month: 'long', day: 'numeric' })} · ${items.length} auto-filled & waiting on your submit</p>
    ${section(`⏰ Lapsing within ${URGENT_DAYS} days — submit now`, urgent, '#c0392b')}
    ${section('This week', soon, '#b8860b')}
    ${section('Coming up', later, '#2c3e50')}
    <p style="margin-top:20px;color:#667;font-size:13px">Each was auto-filled up to the review screen. The final "I certify under penalty of perjury" attestation + Submit is yours by law — nothing was submitted.</p>
    <p><a href="${APP_URL}/settlements" style="color:#0369a1">Open Claims dashboard →</a></p>
  </div>`;
  return { subject, html, urgent };
}

function loadState() { try { return JSON.parse(fs.readFileSync(STATE, 'utf8')); } catch { return { last_date: null, alerted_urgent: [] }; } }
function saveState(s) { try { fs.mkdirSync(path.dirname(STATE), { recursive: true }); fs.writeFileSync(STATE, JSON.stringify(s, null, 2)); } catch { /* best effort */ } }

// force=true bypasses the throttle (manual run). Returns {sent, reason, counts}.
async function sendClaimsAlert(userId = USER, { force = false } = {}) {
  const items = await collect(userId);
  if (!items.length) return { sent: false, skipped: 'no staged claims', total: 0 };

  const st = loadState();
  const newUrgent = items.filter((i) => i.days != null && i.days <= URGENT_DAYS && !st.alerted_urgent.includes(i.id));
  const isNewDay = st.last_date !== today();
  if (!force && !isNewDay && newUrgent.length === 0) {
    return { sent: false, skipped: 'throttled (already alerted today, no new urgent)', total: items.length };
  }

  const { subject, html, urgent } = render(items);
  if (!GEORGE_URL || !GEORGE_BASIC_AUTH || !DIGEST_TO) {
    return { sent: false, skipped: 'george-not-configured (GEORGE_URL/GEORGE_BASIC_AUTH/DIGEST_TO)', total: items.length, urgent: urgent.length };
  }
  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, body: html }),
  });
  const json = await res.json().catch(() => ({}));
  if (!res.ok || json.error) throw new Error(`George send failed (${res.status}): ${json.error || 'unknown'}`);

  saveState({ last_date: today(), alerted_urgent: Array.from(new Set([...st.alerted_urgent, ...urgent.map((u) => u.id)])) });
  return { sent: true, messageId: json.messageId, total: items.length, urgent: urgent.length };
}

module.exports = { collect, render, sendClaimsAlert };