← back to AbramsOS

lib/reminder-engine.js

183 lines

// Reminder engine. Scans purchases + recall_match rows; emits calendar_reminder rows.
// Idempotent — the (user_id, owner_table, owner_id, reason_code) UNIQUE index dedupes.

const db = require('./db');
const audit = require('./audit');
const { id } = require('./ids');

// Default windows. Real merchant policies live in service_commitment / coverage_policy
// (Phase 5 in the roadmap); until then we use pessimistic defaults.
const DEFAULT_RETURNS_DAYS = 30;
const DEFAULT_WARRANTY_DAYS = 365;
const RECALL_ACTION_DAYS = 14;

async function generateForUser(userId) {
  let inserted = 0;

  // 1) Returns-window closing — fire 5 days before window closes
  const purchases = await db.query(
    `SELECT id, merchant_name, purchase_date, total_amount, currency
       FROM purchase WHERE user_id = $1 AND purchase_date IS NOT NULL`,
    [userId]
  );
  for (const p of purchases.rows) {
    const purchaseDate = new Date(p.purchase_date);
    const returnsClose = new Date(purchaseDate.getTime() + DEFAULT_RETURNS_DAYS * 86400e3);
    const daysUntilClose = (returnsClose - Date.now()) / 86400e3;
    if (daysUntilClose > 0 && daysUntilClose <= 7) {
      const r = await tryInsert({
        userId,
        ownerTable: 'purchase',
        ownerId: p.id,
        dueAt: returnsClose,
        reasonCode: 'returns_window_closing',
        title: `Return window for ${p.merchant_name} closes in ${Math.ceil(daysUntilClose)}d`,
        body: `If you intend to return this $${p.total_amount} purchase, do it before ${returnsClose.toDateString()}.`,
      });
      if (r) inserted += 1;
    }

    // 2) Warranty expiry — fire 30 days before
    const warrantyExp = new Date(purchaseDate.getTime() + DEFAULT_WARRANTY_DAYS * 86400e3);
    const daysUntilWarranty = (warrantyExp - Date.now()) / 86400e3;
    if (daysUntilWarranty > 0 && daysUntilWarranty <= 30) {
      const r = await tryInsert({
        userId,
        ownerTable: 'purchase',
        ownerId: p.id,
        dueAt: warrantyExp,
        reasonCode: 'warranty_expiry',
        title: `${p.merchant_name} warranty expires in ${Math.ceil(daysUntilWarranty)}d`,
        body: `Default 1-year manufacturer warranty assumption. If you have an issue, file before ${warrantyExp.toDateString()}.`,
      });
      if (r) inserted += 1;
    }
  }

  // 3) Recall actions — fire 14 days from match creation
  const matches = await db.query(
    `SELECT rm.id, rm.recall_id, re.title AS recall_title, re.url, re.remedy, rm.matched_at, rm.asset_id
       FROM recall_match rm JOIN recall_event re ON re.id = rm.recall_id
      WHERE rm.user_id = $1 AND rm.status = 'pending_review'`,
    [userId]
  );
  for (const m of matches.rows) {
    const dueAt = new Date(new Date(m.matched_at).getTime() + RECALL_ACTION_DAYS * 86400e3);
    const r = await tryInsert({
      userId,
      ownerTable: 'recall_match',
      ownerId: m.id,
      dueAt,
      reasonCode: 'recall_action_due',
      title: `Recall match needs your review: ${m.recall_title || 'CPSC notice'}`,
      body: `Remedy: ${m.remedy || 'see notice'}. Source: ${m.url || 'CPSC'}.`,
    });
    if (r) inserted += 1;
  }

  // 4) Bills due — fire when due within the next 14 days (and while overdue)
  const bills = await db.query(
    `SELECT id, name, payee, amount, currency, due_date, category
       FROM bill WHERE user_id = $1 AND status = 'active' AND due_date IS NOT NULL`,
    [userId]
  );
  for (const b of bills.rows) {
    const daysUntil = (new Date(b.due_date).getTime() - Date.now()) / 86400e3;
    if (daysUntil <= 14) {
      const dueAt = new Date(new Date(b.due_date).getTime() + 12 * 3600e3);
      const amt = b.amount ? `${b.currency} ${Number(b.amount).toFixed(2)}` : '';
      const r = await tryInsert({
        userId, ownerTable: 'bill', ownerId: b.id, dueAt,
        reasonCode: 'bill_due',
        title: `Bill due: ${b.name}${amt ? ' — ' + amt : ''}`,
        body: `${b.payee ? b.payee + '. ' : ''}Due ${new Date(b.due_date).toDateString()}.`,
        metadata: { category: b.category },
      });
      if (r) inserted += 1;
    }
  }

  // 5) Reorders due — fire when we're at/near the repurchase date
  const reorders = await db.query(
    `SELECT id, name, merchant, next_due_date FROM reorder_item
      WHERE user_id = $1 AND status = 'active' AND next_due_date IS NOT NULL`,
    [userId]
  );
  for (const it of reorders.rows) {
    const daysUntil = (new Date(it.next_due_date).getTime() - Date.now()) / 86400e3;
    if (daysUntil <= 7) {
      const dueAt = new Date(new Date(it.next_due_date).getTime() + 12 * 3600e3);
      const r = await tryInsert({
        userId, ownerTable: 'reorder_item', ownerId: it.id, dueAt,
        reasonCode: 'reorder_due',
        title: `Time to reorder: ${it.name}`,
        body: `${it.merchant ? 'Usually from ' + it.merchant + '. ' : ''}Due ${new Date(it.next_due_date).toDateString()}.`,
      });
      if (r) inserted += 1;
    }
  }

  // 6) Warranty / return / guarantee windows closing — fire within 30 days
  const commitments = await db.query(
    `SELECT id, provider_name, commitment_type, refund_window_ends_at
       FROM service_commitment
      WHERE user_id = $1 AND refund_window_ends_at IS NOT NULL`,
    [userId]
  );
  for (const c of commitments.rows) {
    const daysUntil = (new Date(c.refund_window_ends_at).getTime() - Date.now()) / 86400e3;
    if (daysUntil > 0 && daysUntil <= 30) {
      const label = c.provider_name || 'coverage';
      const r = await tryInsert({
        userId, ownerTable: 'service_commitment', ownerId: c.id,
        dueAt: new Date(c.refund_window_ends_at),
        reasonCode: 'coverage_window_closing',
        title: `${label} ${c.commitment_type || 'warranty'} window closes in ${Math.ceil(daysUntil)}d`,
        body: `File any ${c.commitment_type || 'warranty'} claim before ${new Date(c.refund_window_ends_at).toDateString()}.`,
        metadata: { commitment_type: c.commitment_type },
      });
      if (r) inserted += 1;
    }
  }

  // Health: gentle "time for a BP check" nudge for lapsed loggers (never nags new users).
  inserted += await require('./vitals-reminders').generateReadingReminders(userId, tryInsert);

  return inserted;
}

async function tryInsert({ userId, ownerTable, ownerId, dueAt, reasonCode, title, body, metadata = {} }) {
  try {
    const reminderId = id('document'); // reuse ulid prefix
    await db.query(
      `INSERT INTO calendar_reminder (id, user_id, owner_table, owner_id, due_at, reason_code, title, body, metadata_jsonb)
       VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
       ON CONFLICT (user_id, owner_table, owner_id, reason_code) DO NOTHING`,
      [reminderId, userId, ownerTable, ownerId, dueAt, reasonCode, title, body, metadata]
    );
    await audit.log({
      actorType: 'system',
      objectType: 'calendar_reminder',
      objectId: reminderId,
      eventType: 'reminder_generated',
      metadata: { reason_code: reasonCode, owner_table: ownerTable, owner_id: ownerId, due_at: dueAt },
    });
    return true;
  } catch (err) {
    return false;
  }
}

async function upcomingForUser(userId, { days = 30 } = {}) {
  const r = await db.query(
    `SELECT id, owner_table, owner_id, due_at, reason_code, title, body, state
       FROM calendar_reminder
      WHERE user_id = $1 AND state = 'pending' AND due_at < (now() + ($2 || ' days')::interval)
      ORDER BY due_at ASC`,
    [userId, String(days)]
  );
  return r.rows;
}

module.exports = { generateForUser, upcomingForUser, tryInsert };