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