Files
rm-circle-team-router/profiles.js
T
martbost 2f90e1a97f QA pass on the member profile gate: stable button ids, completion opens the dashboard tab, local devCode, 47-test E2E suite
Findings fixed: the gate's buttons had no stable ids (fragile to test and maintain), and finishing the
gate left the member on the pitch tab where the profile card and Messages are not visible, so
completion now opens the Position Dashboard tab. /api/public/profile/email-start returns devCode
outside production so the flow is testable locally, matching the InstantAdPay pattern.

qa/profiles-unit.mjs (28 assertions) and qa/gate-e2e.mjs (47 assertions, real sessions, real UI) with
a README. All pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 17:40:03 -05:00

176 lines
9.1 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);
}
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,
startEmail, verifyEmail, contactFor, setTelegram, positionsFor, coverage, adminList, ensure, USER_RE, EMAIL_RE };