mirror of
https://github.com/screentinker/screentinker.git
synced 2026-06-14 18:22:46 -06:00
feat(signup): T+3 activation nudge for users with zero paired screens
Daily sweep (15:00 UTC) emails a warm, personal "checking in" message to users who signed up 3-14 days ago and still have no paired screen, nudging them toward activation. Once per user, reuses the Graph transport (services/email.js) via the existing fromName/rawSubject options. - New service services/activationNudge.js, started from server.js. Self-correcting daily scheduler (recompute next 15:00 UTC each run; no node-cron dependency). - Eligibility (Option B, workspace-aware): created 3-14 days ago, activation_nudge_sent_at IS NULL, COALESCE(email_alerts,1)=1 (only an explicit opt-out of 0 is excluded; NULL/unset still qualify), and ZERO devices owned by the user OR present in any workspace they belong to. The workspace check avoids nudging engaged team members. - Idempotency: activation_nudge_sent_at, stamped after send; paired sentinel-1 backfill so the first sweep can't blast the dormant legacy base. Only genuinely-new signups become eligible. - GATE: HOSTED_INSTANCE=true (positive hosted signal, NOT !selfHosted). A daily bulk sweep would be far worse to leak than a single email, so a self-hoster who configured Graph but missed SELF_HOSTED won't blast their user base. Unset -> neither scheduled nor sent. Documented in .env.example.
This commit is contained in:
parent
2f78fa1106
commit
cbe00d6c85
|
|
@ -16,6 +16,12 @@ SELF_HOSTED=true
|
|||
# who want to be notified of signups set this to their own address.
|
||||
# ADMIN_NOTIFY_EMAIL=you@example.com
|
||||
|
||||
# Marks THIS deployment as the hosted (screentinker.com) instance. Gates the
|
||||
# daily activation-nudge sweep (the T+3 "haven't paired a screen yet?" email).
|
||||
# Leave UNSET on self-hosted instances so a daily bulk sweep never emails your
|
||||
# user base with our onboarding mail. Only the hosted instance sets this true.
|
||||
# HOSTED_INSTANCE=true
|
||||
|
||||
# --- Outbound email (Microsoft Graph, client-credentials flow) ---
|
||||
# Required for ANY email (welcome, offline alerts, admin notify) to actually
|
||||
# send. Leave blank and the app logs "[EMAIL] not configured" instead of sending.
|
||||
|
|
|
|||
|
|
@ -153,6 +153,15 @@ const migrations = [
|
|||
// sent at <time>". The backfill is idempotent: re-runs match nothing.
|
||||
"ALTER TABLE users ADD COLUMN welcome_email_sent_at INTEGER",
|
||||
"UPDATE users SET welcome_email_sent_at = 1 WHERE welcome_email_sent_at IS NULL",
|
||||
// Slice 3: idempotency guard for the one-time T+3 activation nudge. Same
|
||||
// shape as welcome_email_sent_at: non-null = handled. New signups get a real
|
||||
// unix-seconds stamp when the daily sweep emails them (see
|
||||
// services/activationNudge.js). The paired sentinel-1 backfill marks every
|
||||
// pre-existing user as handled so the FIRST sweep can't blast the entire
|
||||
// dormant legacy base with a stale "you signed up a few days ago" nudge --
|
||||
// only genuinely-new signups (NULL) become eligible going forward.
|
||||
"ALTER TABLE users ADD COLUMN activation_nudge_sent_at INTEGER",
|
||||
"UPDATE users SET activation_nudge_sent_at = 1 WHERE activation_nudge_sent_at IS NULL",
|
||||
];
|
||||
for (const sql of migrations) {
|
||||
try { db.exec(sql); } catch (e) { /* already exists */ }
|
||||
|
|
|
|||
|
|
@ -491,6 +491,10 @@ startScheduler(io);
|
|||
const { startAlertService } = require('./services/alerts');
|
||||
startAlertService(io);
|
||||
|
||||
// Start activation-nudge sweep (T+3 onboarding nudge; gated on HOSTED_INSTANCE)
|
||||
const { startActivationNudge } = require('./services/activationNudge');
|
||||
startActivationNudge();
|
||||
|
||||
// Handle provisioning via WebSocket notification
|
||||
const { db } = require('./db/database');
|
||||
const originalProvisionRoute = require('./routes/provisioning');
|
||||
|
|
|
|||
155
server/services/activationNudge.js
Normal file
155
server/services/activationNudge.js
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
// Activation nudge (Slice 3): a once-per-user "checking in" email sent T+3 days
|
||||
// after signup when the user still has zero paired screens. Daily sweep at a
|
||||
// fixed UTC hour. Reuses the single Microsoft Graph transport (./email).
|
||||
//
|
||||
// GATING — positive hosted signal, NOT !selfHosted:
|
||||
// This is a daily BULK sweep. A self-hoster who configured Graph but forgot
|
||||
// SELF_HOSTED=true would blast their whole dormant user base with Dan-branded
|
||||
// onboarding mail. So we gate on an explicit HOSTED_INSTANCE=true: if it's not
|
||||
// set, we neither schedule nor send. Hosted prod sets the env var.
|
||||
//
|
||||
// Idempotency: users.activation_nudge_sent_at, stamped after each send; the
|
||||
// query's "IS NULL" guard means a user is nudged at most once. Re-runs are safe.
|
||||
//
|
||||
// Opt-out: users who explicitly turned email alerts off (email_alerts = 0) are
|
||||
// excluded; NULL/unset and on (1) both qualify via COALESCE(...,1)=1.
|
||||
|
||||
const { db } = require('../db/database');
|
||||
const { sendEmail } = require('./email');
|
||||
|
||||
const NUDGE_HOUR_UTC = 15; // 15:00 UTC daily
|
||||
|
||||
const LINKS = {
|
||||
player: 'https://screentinker.com/player/',
|
||||
pi: 'https://screentinker.com/guides/raspberry-pi-digital-signage.html',
|
||||
androidTv: 'https://screentinker.com/guides/digital-signage-android-tv.html',
|
||||
selfHosted: 'https://screentinker.com/guides/self-hosted-digital-signage.html',
|
||||
discord: 'https://discord.gg/utTdsrqq4Z',
|
||||
};
|
||||
|
||||
function htmlEscape(s) {
|
||||
return String(s).replace(/[&<>"']/g, c =>
|
||||
({ '&':'&','<':'<','>':'>','"':'"',"'":''' }[c]));
|
||||
}
|
||||
|
||||
// Pure-ASCII plain text (same deliverability rule as the welcome email).
|
||||
function nudgeText(name) {
|
||||
return `Hi ${name},
|
||||
|
||||
You signed up for ScreenTinker a few days ago, and I noticed you
|
||||
haven't paired a screen yet. No worries at all. I just wanted to
|
||||
check in and see if anything's getting in the way.
|
||||
|
||||
If you hit a snag, hit reply and tell me what happened. It comes
|
||||
straight to me and I'll help you sort it.
|
||||
|
||||
If you just haven't had a chance yet, the fastest way to start is the
|
||||
web player. Turn any browser into a screen in about a minute:
|
||||
|
||||
-> ${LINKS.player}
|
||||
|
||||
Or if you're setting up real hardware:
|
||||
- Raspberry Pi: ${LINKS.pi}
|
||||
- Android TV: ${LINKS.androidTv}
|
||||
- Self-hosted: ${LINKS.selfHosted}
|
||||
|
||||
And the Discord is here if you'd rather ask there:
|
||||
${LINKS.discord}
|
||||
|
||||
And if you'd rather I didn't check in, just say the word.
|
||||
|
||||
- Dan
|
||||
ScreenTinker`;
|
||||
}
|
||||
|
||||
function nudgeHtml(name) {
|
||||
return `<div style="font-family:-apple-system,'Segoe UI',Roboto,sans-serif;font-size:15px;line-height:1.6;color:#222;max-width:560px">
|
||||
<p>Hi ${htmlEscape(name)},</p>
|
||||
<p>You signed up for ScreenTinker a few days ago, and I noticed you haven't paired a screen yet. No worries at all. I just wanted to check in and see if anything's getting in the way.</p>
|
||||
<p>If you hit a snag, hit reply and tell me what happened. It comes straight to me and I'll help you sort it.</p>
|
||||
<p>If you just haven't had a chance yet, the fastest way to start is the web player. Turn any browser into a screen in about a minute:</p>
|
||||
<p><a href="${LINKS.player}" style="font-weight:600">Open the web player</a></p>
|
||||
<p>Or if you're setting up real hardware:</p>
|
||||
<ul>
|
||||
<li><a href="${LINKS.pi}">Raspberry Pi setup</a></li>
|
||||
<li><a href="${LINKS.androidTv}">Android TV setup</a></li>
|
||||
<li><a href="${LINKS.selfHosted}">Self-hosted setup</a></li>
|
||||
</ul>
|
||||
<p>And the <a href="${LINKS.discord}">Discord is here</a> if you'd rather ask there.</p>
|
||||
<p>And if you'd rather I didn't check in, just say the word.</p>
|
||||
<p>- Dan<br>ScreenTinker</p>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// Eligible = signed up 3-14 days ago, never nudged, not opted out, and with
|
||||
// ZERO devices either owned by the user OR present in any workspace they belong
|
||||
// to (Option B, workspace-aware — avoids nudging engaged team members).
|
||||
const ELIGIBLE_SQL = `
|
||||
SELECT u.id, u.email, u.name FROM users u
|
||||
WHERE u.created_at < strftime('%s','now') - (3 * 86400)
|
||||
AND u.created_at > strftime('%s','now') - (14 * 86400)
|
||||
AND u.activation_nudge_sent_at IS NULL
|
||||
AND COALESCE(u.email_alerts, 1) = 1
|
||||
AND NOT EXISTS (SELECT 1 FROM devices d WHERE d.user_id = u.id)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM workspace_members wm
|
||||
JOIN devices d2 ON d2.workspace_id = wm.workspace_id
|
||||
WHERE wm.user_id = u.id)
|
||||
`;
|
||||
|
||||
function isHosted() {
|
||||
return process.env.HOSTED_INSTANCE === 'true';
|
||||
}
|
||||
|
||||
// Run one sweep. Exported so the dev verify harness can drive it directly
|
||||
// without waiting for 15:00 UTC. Returns the number of nudges sent.
|
||||
async function runActivationNudgeSweep() {
|
||||
if (!isHosted()) return 0; // defense in depth (scheduler is also gated)
|
||||
const users = db.prepare(ELIGIBLE_SQL).all();
|
||||
console.log(`[NUDGE] sweep: ${users.length} eligible user(s)`);
|
||||
let sent = 0;
|
||||
for (const u of users) {
|
||||
const name = (u.name && u.name.trim()) ? u.name.trim() : u.email.split('@')[0];
|
||||
const r = await sendEmail({
|
||||
to: u.email,
|
||||
fromName: 'Dan at ScreenTinker',
|
||||
rawSubject: true,
|
||||
subject: "Quick check-in - how's ScreenTinker going?",
|
||||
text: nudgeText(name),
|
||||
html: nudgeHtml(name),
|
||||
});
|
||||
console.log(`[NUDGE] nudge -> ${u.email}: ${JSON.stringify(r)}`);
|
||||
// Stamp after the send (no retry, same discipline as the welcome email).
|
||||
db.prepare("UPDATE users SET activation_nudge_sent_at = strftime('%s','now') WHERE id = ?").run(u.id);
|
||||
sent++;
|
||||
}
|
||||
return sent;
|
||||
}
|
||||
|
||||
function msUntilNextRun() {
|
||||
const now = new Date();
|
||||
const next = new Date(Date.UTC(
|
||||
now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate(), NUDGE_HOUR_UTC, 0, 0, 0));
|
||||
if (next.getTime() <= now.getTime()) next.setUTCDate(next.getUTCDate() + 1);
|
||||
return next.getTime() - now.getTime();
|
||||
}
|
||||
|
||||
// Self-correcting daily scheduler (recompute next 15:00 UTC each run; no drift,
|
||||
// no node-cron dependency). Gated on HOSTED_INSTANCE.
|
||||
function startActivationNudge() {
|
||||
if (!isHosted()) {
|
||||
console.log('[NUDGE] HOSTED_INSTANCE not set - activation nudge sweep disabled');
|
||||
return;
|
||||
}
|
||||
const schedule = () => {
|
||||
const delay = msUntilNextRun();
|
||||
console.log(`[NUDGE] next activation-nudge sweep in ~${Math.round(delay / 60000)} min (15:00 UTC daily)`);
|
||||
setTimeout(() => {
|
||||
runActivationNudgeSweep().catch(e => console.error('[NUDGE] sweep error:', e.message));
|
||||
schedule();
|
||||
}, delay);
|
||||
};
|
||||
schedule();
|
||||
}
|
||||
|
||||
module.exports = { startActivationNudge, runActivationNudgeSweep };
|
||||
Loading…
Reference in a new issue