91a6893df9
Manson's objection was that requiring a username and a verified email pulls the
build back toward a centralized database of members. He is right, and the
communication gap is real too, so the answer is to ask well rather than to force.
Nothing about holding a position, getting paid, reading the org, the training or
the tools depends on contact details any more. There is no onboarding gate: a
brand-new member registers, lands on their page and is never stopped by a modal.
The dashboard offers a dismissable card ("Not now" snoozes it for a week) that
leads with the thing members actually want, a note the moment a payout lands in
their wallet, and says outright that everything works the same without it. The
inbox is the one place that asks, because a message cannot be delivered to
someone who left no way to reach them, and even there it is an invitation.
The card sits above the tab strip rather than inside the dashboard pane: the page
opens on the pitch tab, so an invitation parked in the dashboard would never be
seen by the new members it is aimed at.
For leaders, /api/public/reach answers "how many of my org can I reach off the
site", scoped by chain.isInTeam so it leaks nothing upward or sideways. That
makes coverage a leader's own problem to solve by asking, not a rule imposed on
members.
Fixes a real bug found by the rewritten suite: the dismissable flag double-booked
as "single-field edit", so saving a username in the opt-in flow closed the dialog
instead of advancing to the email step. Split into oneShot; the suite now asserts
the advance as a regression.
QA, all green: profiles-unit 28, signin-fallback 7, gate-e2e 33 (rewritten to
assert the opposite of what it used to: no forced modal, dismissable everywhere,
visitors unaffected), join-flow 12 cold / 11 refuse / 12 warm.
qa/reseed.sh carries two hard-won guards: never name a shell variable TMP on
Windows (it inherits the system temp dir and rm -rf wipes it), and never pkill.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
185 lines
9.6 KiB
JavaScript
185 lines
9.6 KiB
JavaScript
// RM Circle member profiles: username + verified email per POSITION.
|
|
//
|
|
// Why position-keyed and not wallet-keyed: the contract allows one position per
|
|
// address, so a Triple Play holder owns three wallets and three positions. The
|
|
// person is the EMAIL; one email may hold several positions (that is expected and
|
|
// surfaced as "your positions"). Everything else on the site is position-keyed
|
|
// too, which is why the existing member-alerts.json seeds straight in.
|
|
//
|
|
// Identity is only ever written by a session that PROVED ownership of the
|
|
// position: a wallet personal_sign (messages.verifyChallenge) or the Telegram
|
|
// Mini App bridge. The public /my/<id> page can never write a profile, because
|
|
// anyone can open it.
|
|
'use strict';
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const crypto = require('crypto');
|
|
|
|
let DATA_DIR = null;
|
|
let sendEmail = () => {};
|
|
const FILE = () => path.join(DATA_DIR, 'profiles.json');
|
|
const ALERTS = () => path.join(DATA_DIR, 'member-alerts.json');
|
|
|
|
const USER_RE = /^[a-z0-9_]{3,20}$/;
|
|
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/;
|
|
const RESERVED = new Set(['admin', 'administrator', 'support', 'rmcircle', 'rm_circle', 'founder', 'founders',
|
|
'official', 'team', 'help', 'moderator', 'mod', 'staff', 'owner', 'ceo', 'system', 'root', 'null', 'undefined']);
|
|
const CODE_TTL = 15 * 60 * 1000;
|
|
const CODE_COOLDOWN = 60 * 1000;
|
|
const CODE_MAX_PER_DAY = 5;
|
|
const CODE_MAX_TRIES = 6;
|
|
|
|
let db = null; // { byId: { "<positionId>": profile }, v: 1 }
|
|
const codes = new Map(); // positionId -> { code, exp, tries, nextAt, email, sentToday, day }
|
|
|
|
function load() {
|
|
try { db = JSON.parse(fs.readFileSync(FILE(), 'utf8')); } catch (e) { db = null; }
|
|
if (!db || db.v !== 1) db = { v: 1, byId: {} };
|
|
if (!db.byId) db.byId = {};
|
|
}
|
|
function save() { try { fs.writeFileSync(FILE(), JSON.stringify(db)); } catch (e) { console.error('profiles save', e.message); } }
|
|
function init(opts) {
|
|
DATA_DIR = opts.dataDir;
|
|
if (opts.sendEmail) sendEmail = opts.sendEmail;
|
|
load();
|
|
seedFromAlerts();
|
|
}
|
|
// The 40 positions that already gave an email for payout alerts: pre-fill it so
|
|
// the gate is one tap for them. NOT marked verified — they never proved the
|
|
// address in a flow we control, and a verify code costs them one click.
|
|
function seedFromAlerts() {
|
|
let a = {};
|
|
try { a = JSON.parse(fs.readFileSync(ALERTS(), 'utf8')); } catch (e) { return; }
|
|
let n = 0;
|
|
for (const [id, rec] of Object.entries(a || {})) {
|
|
const pid = String(Number(id) || 0);
|
|
if (pid === '0' || !rec || !EMAIL_RE.test(String(rec.email || ''))) continue;
|
|
const p = db.byId[pid];
|
|
if (p && (p.email || p.emailVerified)) continue;
|
|
db.byId[pid] = Object.assign({ id: Number(pid), username: null, email: null, emailVerified: false,
|
|
telegramId: null, created: Date.now(), updated: Date.now() }, p || {},
|
|
{ email: String(rec.email).trim().toLowerCase(), emailVerified: false, seededFrom: 'alerts' });
|
|
n++;
|
|
}
|
|
if (n) { save(); console.log('profiles: seeded', n, 'emails from member-alerts.json'); }
|
|
}
|
|
|
|
const norm = id => String(Number(id) || 0);
|
|
function get(id) { const p = db.byId[norm(id)]; return p ? Object.assign({}, p) : null; }
|
|
function pub(p) {
|
|
if (!p) return null;
|
|
return { id: p.id, username: p.username || null, email: p.email || null, emailVerified: !!p.emailVerified,
|
|
telegramId: p.telegramId || null, complete: isComplete(p) };
|
|
}
|
|
function isComplete(p) { return !!(p && p.username && p.email && p.emailVerified); }
|
|
function complete(id) { return isComplete(db.byId[norm(id)]); }
|
|
function ensure(id) {
|
|
const k = norm(id);
|
|
if (!db.byId[k]) { db.byId[k] = { id: Number(k), username: null, email: null, emailVerified: false, telegramId: null, created: Date.now(), updated: Date.now() }; save(); }
|
|
return db.byId[k];
|
|
}
|
|
// what the gate needs to render: the profile plus anything pre-filled for them
|
|
function status(id) {
|
|
const p = get(id) || { id: Number(norm(id)), username: null, email: null, emailVerified: false };
|
|
return { profile: pub(p), needs: { username: !p.username, email: !(p.email && p.emailVerified) } };
|
|
}
|
|
|
|
// ---- username ----
|
|
function usernameTaken(name, exceptId) {
|
|
const k = norm(exceptId);
|
|
for (const [id, p] of Object.entries(db.byId)) if (id !== k && String(p.username || '').toLowerCase() === name) return true;
|
|
return false;
|
|
}
|
|
function setUsername(id, raw) {
|
|
const name = String(raw || '').trim().toLowerCase().replace(/^@/, '');
|
|
if (!USER_RE.test(name)) return { error: 'Usernames are 3 to 20 characters: letters, numbers or underscores.' };
|
|
if (!/[a-z]/.test(name)) return { error: 'Usernames need at least one letter.' };
|
|
if (RESERVED.has(name)) return { error: 'That username is reserved. Pick another.' };
|
|
if (usernameTaken(name, id)) return { error: 'That username is taken. Pick another.' };
|
|
const p = ensure(id);
|
|
p.username = name; p.updated = Date.now(); save();
|
|
return { ok: true, profile: pub(p) };
|
|
}
|
|
function byUsername(name) {
|
|
const n = String(name || '').trim().toLowerCase().replace(/^@/, '');
|
|
for (const p of Object.values(db.byId)) if (String(p.username || '').toLowerCase() === n) return Object.assign({}, p);
|
|
return null;
|
|
}
|
|
function suggest(id) {
|
|
const base = 'member' + norm(id);
|
|
return usernameTaken(base, id) ? base + crypto.randomBytes(1).toString('hex') : base;
|
|
}
|
|
|
|
// ---- email verification ----
|
|
function today() { return new Date().toISOString().slice(0, 10); }
|
|
function startEmail(id, rawEmail) {
|
|
const email = String(rawEmail || '').trim().toLowerCase();
|
|
if (!EMAIL_RE.test(email)) return { error: 'That email address does not look right.' };
|
|
const k = norm(id);
|
|
const st = codes.get(k) || { sentToday: 0, day: today() };
|
|
if (st.day !== today()) { st.sentToday = 0; st.day = today(); }
|
|
if (st.nextAt && Date.now() < st.nextAt) return { error: 'Code already sent. Give it a minute, then try again.' };
|
|
if (st.sentToday >= CODE_MAX_PER_DAY) return { error: 'Too many codes today. Try again tomorrow, or contact support.' };
|
|
const code = String(Math.floor(100000 + Math.random() * 900000));
|
|
codes.set(k, { code, email, exp: Date.now() + CODE_TTL, tries: 0, nextAt: Date.now() + CODE_COOLDOWN, sentToday: st.sentToday + 1, day: st.day });
|
|
const subject = 'Your RM Circle code: ' + code;
|
|
const text = 'Your confirmation code for RM Circle position #' + k + ' is:\n\n ' + code
|
|
+ '\n\nEnter it on your dashboard to finish setting up your member profile. The code lasts 15 minutes.\n\n'
|
|
+ 'Why we ask: your email is how your team leader can reach you and how you get a note the moment a payout lands in your wallet. '
|
|
+ 'It is never shown to other members and never sold.\n\nIf you did not ask for this, ignore it.';
|
|
try { sendEmail(email, subject, text); } catch (e) { console.error('profiles sendEmail', e.message); }
|
|
return { ok: true, sent: true, to: email.replace(/^(.{2}).*(@.*)$/, '$1***$2') };
|
|
}
|
|
function verifyEmail(id, rawCode) {
|
|
const k = norm(id);
|
|
const st = codes.get(k);
|
|
if (!st || !st.code) return { error: 'Ask for a fresh code first.' };
|
|
if (st.exp < Date.now()) { codes.delete(k); return { error: 'That code expired. Ask for a fresh one.' }; }
|
|
st.tries += 1;
|
|
if (st.tries > CODE_MAX_TRIES) { codes.delete(k); return { error: 'Too many tries. Ask for a fresh code.' }; }
|
|
if (String(rawCode || '').trim() !== st.code) return { error: 'That code does not match.' };
|
|
const p = ensure(k);
|
|
p.email = st.email; p.emailVerified = true; p.emailVerifiedAt = Date.now(); p.updated = Date.now();
|
|
delete p.seededFrom;
|
|
codes.delete(k);
|
|
save();
|
|
return { ok: true, profile: pub(p) };
|
|
}
|
|
|
|
// ---- reach: who can actually be contacted, and how ----
|
|
function contactFor(id) {
|
|
const p = db.byId[norm(id)];
|
|
if (!p) return { id: Number(norm(id)), email: null, telegramId: null, reachable: false };
|
|
return { id: p.id, username: p.username || null, email: p.emailVerified ? p.email : null, telegramId: p.telegramId || null,
|
|
reachable: !!(p.emailVerified && p.email) };
|
|
}
|
|
function setTelegram(id, tgId) { const p = ensure(id); p.telegramId = tgId ? String(tgId) : null; p.updated = Date.now(); save(); return pub(p); }
|
|
// every position this email holds (Triple Play and second positions)
|
|
function positionsFor(email) {
|
|
const e = String(email || '').trim().toLowerCase();
|
|
return Object.values(db.byId).filter(p => p.emailVerified && String(p.email || '') === e).map(p => p.id).sort((a, b) => a - b);
|
|
}
|
|
// Reach for one leader's org: how many of the positions below them have given a
|
|
// way to be contacted. This is the number that makes coverage a leader's own
|
|
// problem rather than a rule imposed on members.
|
|
function reachFor(ids) {
|
|
const list = (ids || []).map(Number).filter(Boolean);
|
|
const reachable = [];
|
|
for (const id of list) { const p = db.byId[norm(id)]; if (p && p.emailVerified && p.email) reachable.push(id); }
|
|
return { total: list.length, reachable: reachable.length, ids: reachable.slice(0, 2000) };
|
|
}
|
|
function coverage() {
|
|
const all = Object.values(db.byId);
|
|
return { profiles: all.length, withUsername: all.filter(p => p.username).length,
|
|
withEmail: all.filter(p => p.email).length, verified: all.filter(p => p.emailVerified).length,
|
|
withTelegram: all.filter(p => p.telegramId).length,
|
|
complete: all.filter(isComplete).length };
|
|
}
|
|
function adminList() { return Object.values(db.byId).map(pub).sort((a, b) => a.id - b.id); }
|
|
|
|
// local testing only: the server exposes this outside production, never on the live site
|
|
function peekCode(id) { const st = codes.get(norm(id)); return st ? st.code : null; }
|
|
|
|
module.exports = { init, get, pub, status, complete, isComplete, setUsername, byUsername, suggest, peekCode, reachFor,
|
|
startEmail, verifyEmail, contactFor, setTelegram, positionsFor, coverage, adminList, ensure, USER_RE, EMAIL_RE };
|