Files
recap/server/subscription-reminders.js
Keysat 0ae59f3550 Add multi-tenant cloud mode: self-serve purchase, credit metering, core-decoupling
Introduces RECAP_MODE=multi alongside single-mode self-host:
- Tenant auth + accounts (magic-link via System SMTP), per-tenant credit pool,
  anonymous trial minting with per-IP/-64 caps
- Self-serve Pro/Max purchase: inline Lightning (BTCPay) + card (Zaprite),
  prepaid 30-day periods, expiry-reminder emails
- Core-decoupling: relay owns cloud tier/expiry keyed by Recaps user-id
- SQLite (better-sqlite3) schema for multi-mode; filesystem unchanged for single
- StartOS actions/versions through 0.2.155
2026-06-13 14:25:05 -05:00

174 lines
6.1 KiB
JavaScript

// Self-serve subscription expiry reminders (multi-mode / cloud only).
//
// The relay owns each user's prepaid-period expiry (subscription source of
// truth); Recaps owns the email address + the recaps.cc SMTP transport. So
// a daily scan here asks the relay who's expiring (or just lapsed), maps
// user_id → users.email, and sends a reminder via the same sendMail() the
// magic-link flow uses. The subscription_reminders table dedups so each
// (user, period, kind) email goes out at most once; a renewal changes the
// expiry instant, which re-arms a fresh set of reminders for the new period.
//
// Self-gating: no-ops unless SMTP is configured, the public URL is set, and
// the relay is reachable. Safe to start unconditionally in multi mode.
import { getDb } from "./db.js";
import { sendMail, isSmtpReady } from "./smtp.js";
import { renderSubscriptionReminderEmail } from "./email-template.js";
import { getRelayExpiringSubscriptions } from "./providers/relay.js";
import { getConfigSnapshot } from "./config.js";
// Reminder thresholds, in days before expiry. Each maps to a distinct
// `kind` so a user gets one heads-up at each crossing (deduped by kind).
const UPCOMING_THRESHOLDS = [
{ days: 7, kind: "upcoming_7d" },
{ days: 1, kind: "upcoming_1d" },
];
const LAPSED_KIND = "lapsed";
// Only email recently-lapsed users; after this they age out of the relay
// window (and we never re-send the same period's lapsed notice anyway).
const LAPSED_WINDOW_DAYS = 3;
const SCAN_INTERVAL_MS = 12 * 60 * 60 * 1000; // twice a day
const BOOT_DELAY_MS = 90 * 1000; // first scan ~90s after boot
let scanning = false;
let scheduled = false;
// Decide which single reminder kind (if any) applies to a subscription
// right now. Pure — exported for testing. `sub` is a relay row:
// { tier, expires_at, expired, days_left }.
export function reminderKindFor(
sub,
{ upcoming = UPCOMING_THRESHOLDS, lapsedWindowDays = LAPSED_WINDOW_DAYS } = {},
) {
if (!sub || typeof sub.days_left !== "number") return null;
const daysLeft = sub.days_left;
if (sub.expired) {
// Recently lapsed → a single lapsed notice (days_left is <= 0).
return daysLeft >= -lapsedWindowDays ? LAPSED_KIND : null;
}
// Upcoming: smallest threshold the sub has crossed wins, so daysLeft=1
// sends 'upcoming_1d' while daysLeft in (1, 7] sends 'upcoming_7d'.
const sorted = [...upcoming].sort((a, b) => a.days - b.days);
for (const t of sorted) {
if (daysLeft <= t.days) return t.kind;
}
return null;
}
function alreadySent(db, userId, periodIso, kind) {
return !!db
.prepare(
"SELECT 1 FROM subscription_reminders WHERE user_id=? AND period_expires_at=? AND kind=?",
)
.get(userId, periodIso, kind);
}
function recordSent(db, userId, periodIso, kind) {
db.prepare(
"INSERT OR IGNORE INTO subscription_reminders (user_id, period_expires_at, kind, sent_at) VALUES (?,?,?,?)",
).run(userId, periodIso, kind, Date.now());
}
function maskEmail(email) {
return String(email).replace(/^(.).*(@.*)$/, "$1***$2");
}
// One scan pass. Returns a small summary object; never throws (logs +
// returns a {skipped} reason instead) so the scheduler stays alive.
export async function runReminderScan({ force = false } = {}) {
if (scanning && !force) return { skipped: "already_running" };
scanning = true;
try {
if (!isSmtpReady()) return { skipped: "smtp_not_ready" };
const snap = await getConfigSnapshot();
const publicUrl = (snap.recap_public_url || "").trim().replace(/\/$/, "");
if (!publicUrl) return { skipped: "public_url_not_set" };
const maxDays = Math.max(1, ...UPCOMING_THRESHOLDS.map((t) => t.days));
const report = await getRelayExpiringSubscriptions({
withinDays: maxDays,
lapsedDays: LAPSED_WINDOW_DAYS,
});
if (!report || !Array.isArray(report.subscriptions)) {
return { skipped: "relay_unavailable" };
}
const db = getDb();
const manageUrl = `${publicUrl}/?renew=1`;
let sent = 0;
let skipped = 0;
for (const sub of report.subscriptions) {
const kind = reminderKindFor(sub);
if (!kind) {
skipped++;
continue;
}
const periodIso = sub.expires_at;
if (!periodIso || alreadySent(db, sub.user_id, periodIso, kind)) {
skipped++;
continue;
}
const u = db
.prepare("SELECT email FROM users WHERE id=?")
.get(sub.user_id);
const email = (u?.email || "").trim();
if (!email) {
// No local user for this id (e.g. another instance's tenant) — skip.
skipped++;
continue;
}
const message = renderSubscriptionReminderEmail({
brandName: "Recaps",
tier: sub.tier,
expiresAt: sub.expires_at,
daysLeft: sub.days_left,
kind,
manageUrl,
});
try {
await sendMail({
to: email,
subject: message.subject,
text: message.text,
html: message.html,
});
recordSent(db, sub.user_id, periodIso, kind);
sent++;
console.log(
`[reminders] sent ${kind} to ${maskEmail(email)} (${sub.tier}, ${sub.days_left}d)`,
);
} catch (err) {
// Don't record on failure → retried next scan.
console.warn(
`[reminders] sendMail failed for ${sub.user_id}: ${err?.message || err}`,
);
skipped++;
}
}
if (sent) {
console.log(`[reminders] scan complete: ${sent} sent, ${skipped} skipped`);
}
return { sent, skipped };
} catch (err) {
console.warn(`[reminders] scan error: ${err?.message || err}`);
return { skipped: "error", error: err?.message || String(err) };
} finally {
scanning = false;
}
}
// Start the daily-ish scan loop. Idempotent. Self-gates inside the scan,
// so it's safe to call whenever multi mode boots.
export function startReminderScheduler() {
if (scheduled) return;
scheduled = true;
setTimeout(() => {
runReminderScan().catch(() => {});
}, BOOT_DELAY_MS);
setInterval(() => {
runReminderScan().catch(() => {});
}, SCAN_INTERVAL_MS);
console.log("[reminders] expiry-reminder scheduler started");
}