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
This commit is contained in:
@@ -0,0 +1,173 @@
|
||||
// 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");
|
||||
}
|
||||
Reference in New Issue
Block a user