0ae59f3550
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
174 lines
6.1 KiB
JavaScript
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");
|
|
}
|