← 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 };