Linked positions (Qualified Start): extra wallets on one account, credits pooled, buy-from picker

- accounts/db: positions table + JSON store (add/list/owner/remove; main-wallet and cross-account guards)
- auth verify: asPosition links a second wallet without touching the session; position wallets can't mint a session
- /api/my/positions (chain-refreshed member ids, buyer counts, credits) + remove
- credits pooled across main + positions on /api/me, dashboard, campaigns; campaign charged to the best-funded position
- My line: own positions listed on level 1 as 'You · position N'
- Buy pane: Qualified Start card + Add a position flow + Buy-from picker with connected-wallet guard
- Wallet pane: Your positions card; chatbot answer updated

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
martbost
2026-09-09 17:18:47 -05:00
parent b86dbfd9e9
commit 046464b83e
9 changed files with 327 additions and 14 deletions
+63 -2
View File
@@ -49,6 +49,7 @@ const J = {
try { this.db = JSON.parse(fs.readFileSync(this.FILE(), 'utf8')); } catch (e) {}
if (!this.db || !this.db.v) this.db = { v: 2, byEmail: {}, byAddress: {}, byCode: {}, joins: 0 };
if (!this.db.byCode) this.db.byCode = {};
if (!this.db.positions) this.db.positions = {};
for (const a of Object.values(this.db.byEmail)) {
if (!a.code) { a.code = newCode(c => this.db.byCode[c]); this.db.byCode[a.code] = a.email; }
else if (!this.db.byCode[a.code]) this.db.byCode[a.code] = a.email;
@@ -160,6 +161,7 @@ const J = {
if (acct.address && acct.address !== a) return { error: 'This account is already linked to wallet '
+ acct.address.slice(0, 6) + '…' + acct.address.slice(-4) + '. Connect that wallet instead.' };
if (this.db.byAddress[a] && this.db.byAddress[a] !== e) return { error: 'That wallet is already linked to a different account.' };
if (this.db.positions[a]) return { error: 'That wallet is already a linked position' + (this.db.positions[a].email === e ? ' on this account.' : ' on a different account.') };
acct.address = a;
this.db.byAddress[a] = e;
this.save();
@@ -172,7 +174,32 @@ const J = {
acct.sponsorRef = ref; this.save();
return { ok: true, account: pub(acct) };
},
async count() { return Object.keys(this.db.byEmail).length; }
async count() { return Object.keys(this.db.byEmail).length; },
// ---- linked positions (extra wallets on one account) ----
async positions(e) {
return Object.entries(this.db.positions).filter(([, p]) => p.email === e)
.map(([address, p]) => ({ address, email: p.email, memberId: p.memberId || 0, created: p.created }))
.sort((x, y) => x.created - y.created);
},
async addPosition(e, a) {
const acct = this.db.byEmail[e];
if (!acct) return { error: 'No such account.' };
if (!acct.address) return { error: 'Link your main wallet first.' };
if (acct.address === a) return { error: 'That is your main wallet. Switch to a different account in your wallet app, then try again.' };
if (this.db.byAddress[a]) return { error: 'That wallet is already the main wallet of another account.' };
const cur = this.db.positions[a];
if (cur && cur.email !== e) return { error: 'That wallet is already a position on a different account.' };
if (!cur) { this.db.positions[a] = { email: e, memberId: 0, created: Date.now() }; this.save(); }
return { ok: true, address: a, created: !cur };
},
async setPositionMember(a, id) { const p = this.db.positions[a]; if (p && p.memberId !== id) { p.memberId = id; this.save(); } },
async positionOwner(a) { const p = this.db.positions[a]; return p ? { address: a, email: p.email, memberId: p.memberId || 0 } : null; },
async removePosition(e, a) {
const p = this.db.positions[a];
if (!p || p.email !== e) return { error: 'No such position.' };
if (p.memberId) return { error: 'That position is already registered on-chain and cannot be unlinked.' };
delete this.db.positions[a]; this.save(); return { ok: true };
},
};
// ---- MySQL mode ----
@@ -278,6 +305,8 @@ const D = {
if (!cur) return { error: 'No such account.' };
if (cur.address && cur.address !== a) return { error: 'This account is already linked to wallet '
+ cur.address.slice(0, 6) + '…' + cur.address.slice(-4) + '. Connect that wallet instead.' };
const pos = (await db.q('SELECT email FROM positions WHERE address=?', [a]))[0];
if (pos) return { error: 'That wallet is already a linked position' + (pos.email === e ? ' on this account.' : ' on a different account.') };
try { await db.q('UPDATE accounts SET address=? WHERE email=?', [a, e]); }
catch (err) {
if (err.code === 'ER_DUP_ENTRY') return { error: 'That wallet is already linked to a different account.' };
@@ -291,7 +320,34 @@ const D = {
if (!r.affectedRows) return { error: 'No such account.' };
return { ok: true, account: await this.byEmail(e) };
},
async count() { const r = await db.q('SELECT COUNT(*) n FROM accounts'); return Number(r[0].n); }
async count() { const r = await db.q('SELECT COUNT(*) n FROM accounts'); return Number(r[0].n); },
// ---- linked positions (extra wallets on one account) ----
async positions(e) {
const rows = await db.q('SELECT * FROM positions WHERE email=? ORDER BY created', [e]);
return rows.map(r => ({ address: r.address, email: r.email, memberId: r.member_id || 0, created: Number(r.created) }));
},
async addPosition(e, a) {
const acct = await this.byEmail(e);
if (!acct) return { error: 'No such account.' };
if (!acct.address) return { error: 'Link your main wallet first.' };
if (acct.address === a) return { error: 'That is your main wallet. Switch to a different account in your wallet app, then try again.' };
if (await this.byAddress(a)) return { error: 'That wallet is already the main wallet of another account.' };
const cur = (await db.q('SELECT * FROM positions WHERE address=?', [a]))[0];
if (cur && cur.email !== e) return { error: 'That wallet is already a position on a different account.' };
if (!cur) await db.q('INSERT INTO positions (address,email,member_id,created) VALUES (?,?,0,?)', [a, e, Date.now()]);
return { ok: true, address: a, created: !cur };
},
async setPositionMember(a, id) { await db.q('UPDATE positions SET member_id=? WHERE address=? AND member_id<>?', [id, a, id]); },
async positionOwner(a) {
const r = (await db.q('SELECT * FROM positions WHERE address=?', [a]))[0];
return r ? { address: r.address, email: r.email, memberId: r.member_id || 0 } : null;
},
async removePosition(e, a) {
const r = (await db.q('SELECT * FROM positions WHERE address=? AND email=?', [a, e]))[0];
if (!r) return { error: 'No such position.' };
if (r.member_id) return { error: 'That position is already registered on-chain and cannot be unlinked.' };
await db.q('DELETE FROM positions WHERE address=?', [a]); return { ok: true };
},
};
const impl = () => db.enabled() ? D : J;
@@ -392,4 +448,9 @@ module.exports = { init, signup, login, ensure, byEmail, byAddress, byCode, byUs
getMutes: e => impl().getMutes(String(e || '').toLowerCase()),
setMute: (o, t, m) => impl().setMute(String(o || '').toLowerCase(), String(t || '').toLowerCase(), m),
sponsorOf, isDownlineOf, getChatSettings,
positions: e => impl().positions(normEmail(e)),
addPosition: (e, a) => { const x = normAddr(a); return /^0x[0-9a-f]{40}$/.test(x) ? impl().addPosition(normEmail(e), x) : Promise.resolve({ error: 'Bad wallet address.' }); },
setPositionMember: (a, id) => impl().setPositionMember(normAddr(a), Number(id) || 0),
positionOwner: a => impl().positionOwner(normAddr(a)),
removePosition: (e, a) => impl().removePosition(normEmail(e), normAddr(a)),
byMemberId: id => impl().byMemberId(id) };