diff --git a/placement.js b/placement.js new file mode 100644 index 0000000..3b1b487 --- /dev/null +++ b/placement.js @@ -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 }; diff --git a/public/my.js b/public/my.js index ea56f3b..21f668f 100644 --- a/public/my.js +++ b/public/my.js @@ -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/ 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 @@

${dd?'Rotation is switched off for this position, so the link above already places every join under you. To send someone into the team rotation instead, add ?direct=0 to it.':`The link above is your Team link ${q2?'— now that you’re 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 under YOU as spillover instead? Use your Direct link:`}

${esc(directUrl)}

${dd?'Either way you keep the entry reward on anyone you personally bring.':'Team link helps your team qualify. Direct link places every join under you (own spillover). Either way you keep the entry reward on anyone you personally bring.'}

+ +
+
\u2699\ufe0f How your link places people
+

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.

+
+ + +
+

${dd + ? 'Every join through your page lands directly under you as depth in your own leg.' + : 'A join through your page goes to the next position in your own team that still needs its 2 \u2014 your downline, in order. It is never handed to the company rotation or to anyone outside your team.'}

+

${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.' : ''}

`; + { + 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(){ diff --git a/qa/placement.mjs b/qa/placement.mjs new file mode 100644 index 0000000..5f6d549 --- /dev/null +++ b/qa/placement.mjs @@ -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); diff --git a/server.js b/server.js index 0472895..44c7b40 100644 --- a/server.js +++ b/server.js @@ -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,12 +730,12 @@ 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{ - r.joinTarget={id,referralUrl:r.referralUrl,reason:'spillover'}; - } + // 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: @@ -742,8 +744,9 @@ async function handleApi(req,res,pathname){ // built out that is backwards - this flips the default so /join/ 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'