Members choose how their link places people

Terry asked how a referral of his would be set up as "Direct with
spillover", and whether his team could follow suit. Two settings, on the
member's own dashboard:

  Team rotation — the join goes to the next position IN THEIR OWN TEAM
                  that still needs its 2
  Direct        — every join lands directly under them, as depth

A new position starts on whatever its SPONSOR was using at the moment it
joined, so a team duplicates its leader by default. That is a one-time
copy, not a live link: a sponsor changing their mind later never moves
anyone already in their team, and an explicit choice is never overwritten
from above. The panel says where an inherited setting came from, so nobody
discovers months later that their link was doing something they did not
pick.

Also closed the hole behind Terry's original complaint: a member's
personal link no longer falls through to the COMPANY rotation when their
leg is fully qualified. Team rotation means rotation inside their own
downline and nothing else — handing someone's referral to a position they
have never met was never the intent, and it is what offered Terry's
prospects #148.

The five positions already on directDefaultIds (21, 137, 139, 30, 840)
read as explicit Direct, so nothing changes for them. Everyone else stays
on Team rotation, which is what they have today.

qa/placement.mjs, 17 checks: defaults, choosing, inheritance from the
sponsor, explicit choices surviving a re-run, a sponsor switching not
moving their existing team, and persistence across a restart.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
martbost
2026-09-18 13:54:14 -05:00
parent 80461a948d
commit 07ab4cec79
4 changed files with 205 additions and 8 deletions
+93
View File
@@ -0,0 +1,93 @@
// Where a member's personal link places the people who use it.
//
// Two settings, chosen by the member:
//
// 'team' — the moving link. A join is routed to the next position IN THEIR OWN LEG that
// still needs directs. This is rotation inside their downline, never the company
// rotation: a member's personal link must never hand their referral to someone
// they have never met. (Marty, 2026-09-18 — exactly what went wrong for Terry
// #840, whose link was offering company position #148.)
// 'direct' — every join lands directly under them, as depth in their own leg.
//
// A new member STARTS on whatever their sponsor was using at the moment they joined — so a
// team duplicates its leader without anyone being locked in. That is a one-time copy, not a
// live link: if the sponsor changes their mind later, nobody already in the team moves. An
// explicit choice is never overwritten from above.
//
// `explicit` records whether the member chose it or simply inherited it. It is what lets the
// dashboard say where the setting came from, and it is why a third "follow my sponsor" state
// was not needed.
'use strict';
const fs = require('fs');
const path = require('path');
let DATA_DIR = null;
let FILE = null;
let db = null; // { ids: () => configured always-direct ids }
function init(opts) {
DATA_DIR = opts.dataDir;
FILE = path.join(DATA_DIR, 'placement.json');
db = opts;
}
function load() {
try { return JSON.parse(fs.readFileSync(FILE, 'utf8')) || {}; } catch (e) { return {}; }
}
function save(o) {
try {
const tmp = FILE + '.tmp';
fs.writeFileSync(tmp, JSON.stringify(o));
fs.renameSync(tmp, FILE);
} catch (e) { console.error('placement save', e.message); }
}
const MODES = ['team', 'direct'];
const clean = m => (MODES.includes(String(m)) ? String(m) : null);
// The admin list stays as a support override and as the migration path for the positions that
// were on it before this existed. An entry there reads as an explicit 'direct'.
function adminDirectIds(cfg) {
return String((cfg && cfg.directDefaultIds) || '')
.split(',').map(s => s.trim()).filter(Boolean);
}
// What is this position set to, and where did that come from?
function get(id, cfg) {
const key = String(id);
const rec = load()[key];
if (rec && clean(rec.mode)) {
return { mode: rec.mode, explicit: !!rec.explicit, from: rec.from || null, ts: rec.ts || null };
}
if (adminDirectIds(cfg).includes(key)) {
return { mode: 'direct', explicit: true, from: 'admin', ts: null };
}
return { mode: 'team', explicit: false, from: null, ts: null };
}
// The member chose it themselves.
function set(id, mode) {
const m = clean(mode);
if (!m) return { error: 'Pick either team or direct.' };
const o = load();
o[String(id)] = { mode: m, explicit: true, from: null, ts: Date.now() };
save(o);
return { ok: true, ...get(id) };
}
// A new position inherits its sponsor's CURRENT setting, once, at join. Never overwrites a
// member who has already chosen, and never touches anyone else in the leg.
function inherit(newId, sponsorId, cfg) {
const key = String(newId);
const o = load();
if (o[key] && o[key].explicit) return { skipped: 'already chosen' };
const parent = get(sponsorId, cfg);
if (parent.mode === 'team') { // the default; no row needed
if (!o[key]) return { skipped: 'default' };
}
o[key] = { mode: parent.mode, explicit: false, from: String(sponsorId), ts: Date.now() };
save(o);
return { ok: true, mode: parent.mode, from: String(sponsorId) };
}
module.exports = { init, get, set, inherit, MODES };
+26
View File
@@ -799,6 +799,7 @@
// page has always honoured that; this panel did not, and told the member their link
// routed to someone else. Two different answers about where their people land.
const dd=!!d.directDefault;
const pl=d.placement||null;
// The personal QR is ALWAYS shown: /join/<id> self-rotates via the moving
// link, so a scan is always placed correctly — even after 2/2. This is the
// person-to-person flow: open your page, tap the QR, they scan, they see
@@ -824,7 +825,32 @@
<p class="micro" style="margin:0 0 8px">${dd?'Rotation is switched off for this position, so the link above already places every join <strong style="color:var(--text)">under you</strong>. To send someone into the team rotation instead, add <strong style="color:var(--text)">?direct=0</strong> to it.':`The link above is your <strong style="color:var(--text)">Team link</strong> ${q2?'— now that youre qualified it routes new joins to the next teammate who needs directs, building your team in order (recommended).':'— it credits you and builds your first two.'} Want new people to land <strong style="color:var(--text)">under YOU</strong> as spillover instead? Use your <strong style="color:var(--text)">Direct link</strong>:`}</p>
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center"><button id="copyDirect" class="btn btn-secondary btn-sm">Copy Direct link</button><span class="micro" style="word-break:break-all;color:var(--muted)">${esc(directUrl)}</span></div>
<p class="micro" style="margin:8px 0 0">${dd?'Either way you keep the entry reward on anyone you personally bring.':'<strong style="color:var(--text)">Team link</strong> helps your team qualify. <strong style="color:var(--text)">Direct link</strong> places every join under you (own spillover). Either way you keep the entry reward on anyone you personally bring.'}</p>
</div>
<div style="margin-top:16px;padding-top:14px;border-top:1px solid var(--line)">
<div style="font-weight:800;font-size:14px;margin-bottom:4px">\u2699\ufe0f How your link places people</div>
<p class="micro" style="margin:0 0 10px">This is your choice, and you can change it any time. It only affects where NEW joins are registered \u2014 nobody already placed ever moves.</p>
<div style="display:flex;gap:8px;flex-wrap:wrap">
<button id="plTeam" class="btn btn-sm ${dd?'btn-secondary':'btn-teal'}" data-mode="team">\ud83d\udc65 Team rotation</button>
<button id="plDirect" class="btn btn-sm ${dd?'btn-teal':'btn-secondary'}" data-mode="direct">\u2b07\ufe0f Direct under me</button>
</div>
<p class="micro" id="plExplain" style="margin:10px 0 0">${dd
? 'Every join through your page lands <strong style="color:var(--text)">directly under you</strong> as depth in your own leg.'
: 'A join through your page goes to the next position <strong style="color:var(--text)">in your own team</strong> that still needs its 2 \u2014 your downline, in order. It is never handed to the company rotation or to anyone outside your team.'}</p>
<p class="micro" id="plFrom" style="margin:6px 0 0;color:var(--muted)">${pl && !pl.explicit && pl.from && pl.from!=='admin'
? 'Starting setting, copied from your sponsor #'+esc(pl.from)+' when you joined. Change it whenever you like.' : ''}</p>
</div>`;
{
const setMode=async m=>{
try{
const r=await (await fetch('/api/public/placement',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({mode:m})})).json();
if(r&&r.error){ const ex=document.getElementById('plExplain'); if(ex)ex.textContent=r.error; return; }
load(d.id); // redraw from the server so the panel and the copy can never disagree
}catch(e){}
};
const bt=document.getElementById('plTeam'), bd=document.getElementById('plDirect');
if(bt)bt.addEventListener('click',()=>setMode('team'));
if(bd)bd.addEventListener('click',()=>setMode('direct'));
}
const qr=document.getElementById('dQr');if(qr)qr.innerHTML=qrSvg(dashUrl);
const qb=document.getElementById('dQrBtn');
if(qb)qb.addEventListener('click',function(){
+60
View File
@@ -0,0 +1,60 @@
// The placement setting: two states, inherited once from the SPONSOR at join,
// and an explicit choice that nothing above can overwrite.
import { createRequire } from 'node:module';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
const require = createRequire(import.meta.url);
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'rmc-pl-'));
const placement = require('../placement.js');
placement.init({ dataDir: dir });
const ok = [], bad = [];
const t = (n, c, extra) => { (c ? ok : bad).push(n + (c || !extra ? '' : ' -> ' + extra)); };
const cfg = { directDefaultIds: '21,137,139,30,840' };
// defaults
t('an unknown position defaults to team rotation', placement.get(999, cfg).mode === 'team');
t('and is not marked as an explicit choice', placement.get(999, cfg).explicit === false);
t('a position on the admin list reads as direct', placement.get(840, cfg).mode === 'direct');
t('the admin list counts as explicit, so nothing overwrites it', placement.get(840, cfg).explicit === true);
// choosing
placement.set(500, 'direct');
t('a member can choose direct', placement.get(500, cfg).mode === 'direct');
t('their choice is recorded as explicit', placement.get(500, cfg).explicit === true);
placement.set(500, 'team');
t('and can switch back', placement.get(500, cfg).mode === 'team');
t('a nonsense value is refused', !!placement.set(500, 'sideways').error);
// inheritance: one-time copy from the sponsor
placement.inherit(601, 500, cfg); // sponsor 500 is on team
t('joining under a team-rotation sponsor gives team', placement.get(601, cfg).mode === 'team');
placement.inherit(602, 840, cfg); // sponsor 840 is direct
t('joining under a direct sponsor gives direct', placement.get(602, cfg).mode === 'direct');
t('inherited settings are marked NOT explicit', placement.get(602, cfg).explicit === false);
t('and remember which sponsor they came from', placement.get(602, cfg).from === '840');
// the rule that keeps the tree readable
placement.set(602, 'team'); // the member disagrees with their sponsor
placement.inherit(602, 840, cfg); // a later re-run must not undo that
t('an explicit choice is never overwritten from above', placement.get(602, cfg).mode === 'team');
t('and stays marked as their own', placement.get(602, cfg).explicit === true);
// a sponsor changing their mind does NOT move people already placed
placement.set(700, 'team');
placement.inherit(701, 700, cfg);
placement.set(700, 'direct'); // sponsor switches later
t('a sponsor switching later does not move their existing team', placement.get(701, cfg).mode === 'team');
placement.inherit(702, 700, cfg); // but new joins get the new setting
t('but people who join after do get the new setting', placement.get(702, cfg).mode === 'direct');
// survives a restart
const fresh = fs.readFileSync(path.join(dir, 'placement.json'), 'utf8');
t('settings are persisted, not just in memory', fresh.includes('"602"') && fresh.includes('"700"'));
fs.rmSync(dir, { recursive: true, force: true });
console.log('PASS ' + ok.length);
for (const b of bad) console.log('FAIL ' + b);
process.exit(bad.length ? 1 : 0);
+25 -7
View File
@@ -6,6 +6,7 @@ const { URL } = require('url');
const chain = require('./chain');
const messages = require('./messages');
const profiles = require('./profiles');
const placement = require('./placement');
const tweet = require('./tweet');
const PORT = Number(process.env.PORT || 3000);
@@ -19,6 +20,7 @@ const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'changeme';
const IS_PROD = process.env.NODE_ENV === 'production';
messages.init({ dataDir: DATA_DIR, chain, isProd: IS_PROD });
profiles.init({ dataDir: DATA_DIR, sendEmail: sendEmailRaw });
placement.init({ dataDir: DATA_DIR });
const suiteMeter = require('./suite-meter'); suiteMeter.init({ dataDir: DATA_DIR });
const suiteAI = require('./suite-ai'); suiteAI.init({ dataDir: DATA_DIR });
const suitePages = require('./suite-pages'); suitePages.init({ dataDir: DATA_DIR });
@@ -728,22 +730,23 @@ async function handleApi(req,res,pathname){
}else if(r.nextInLine){
r.joinTarget={id:r.nextInLine.id,referralUrl:r.nextInLine.referralUrl,invitedBy:id,reason:'leg',directCount:r.nextInLine.directCount};
}else{
let g=null;try{g=activeSponsor(getSponsors());}catch(e){}
if(g&&String(g.id)!==String(id)&&(g.directs||0)<2){
r.joinTarget={id:g.id,referralUrl:`${base}${encodeURIComponent(g.id)}`,invitedBy:id,reason:'global',directCount:g.directs};
}else{
// Whole leg qualified: the join lands on the member themselves as depth. It does NOT
// fall through to the COMPANY rotation — that would hand a member's own referral to
// a position they have never met. "Team rotation" means rotation inside their own
// downline, and nothing else. (Marty, 2026-09-18; this fall-through is what offered
// Terry #840's prospects company position #148.)
r.joinTarget={id,referralUrl:r.referralUrl,reason:'spillover'};
}
}
}
// Positions that default to DIRECT placement. A moving link is team-first:
// it routes new joins to whoever needs directs next, so the position whose
// link was actually clicked earns nothing. For a position being deliberately
// built out that is backwards - this flips the default so /join/<id> behaves
// as ?direct=1 unless ?direct=0 is passed explicitly.
try{
const dd=String((getConfig().directDefaultIds)||'').split(',').map(x=>x.trim()).filter(Boolean);
r.directDefault = dd.includes(String(id));
const pl=placement.get(id,getConfig());
r.directDefault = pl.mode==='direct';
r.placement = pl; // mode + whether they chose it + which sponsor it came from
}catch(e){ r.directDefault = false; }
// Next-step plan + funded badge. Wallet balance is checked server-side and
@@ -1530,6 +1533,18 @@ async function handleApi(req,res,pathname){
if(!s)return json(res,200,{ok:true,signedIn:false});
return json(res,200,Object.assign({ok:true,id:s.id,suggest:profiles.suggest(s.id)},profiles.status(s.id)));
}
if(req.method==='GET'&&pathname==='/api/public/placement'){
const pid=new URL(req.url,'http://x').searchParams.get('id');
if(!/^[0-9]{1,15}$/.test(String(pid||'')))return json(res,400,{error:'id required'});
return json(res,200,placement.get(pid,getConfig()));
}
if(req.method==='POST'&&pathname==='/api/public/placement'){
const ps=messages.authFromCookie(req);
if(!ps)return json(res,401,{error:'Not signed in.'});
const pb=await bodyJson(req).catch(()=>null);
const pr=placement.set(ps.id,pb&&pb.mode);
return json(res,pr.error?400:200,pr);
}
if(req.method==='POST'&&pathname==='/api/public/profile/username'){
const s=messages.authFromCookie(req);
if(!s)return json(res,401,{error:'Not signed in.'});
@@ -1915,6 +1930,9 @@ chain.startIndexer(evt=>{
try{
const c=getConfig();
const EK=tgEventKey(evt); // one id for this on-chain event, so no feed can post it twice
// A new position starts on its sponsor's CURRENT setting - a one-time copy, so a team
// duplicates its leader without anyone already in it being moved later.
if(evt.type==='registered'&&evt.referrerId){ try{ placement.inherit(evt.id,evt.referrerId,c); }catch(e){} }
// teamRootId accepts a comma list ("21,136") — alerts fire for ANY listed org
const roots=String(c.teamRootId||'').split(',').map(n=>Number(n.trim())).filter(n=>n>0);
// A brand-new member has no uplineId yet at the moment the 'registered'