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:
@@ -87,6 +87,14 @@ async function uplineSlides(email, depth = 3) {
|
||||
// thresholds that open payout levels 2 and 3). Until then, or while an unlocked
|
||||
// slot is empty, the slot shows an upline's banner, then a house ad.
|
||||
const wallUnlockedFor = bc => (bc >= 5 ? 3 : bc >= 2 ? 2 : 1);
|
||||
// every on-chain member id this session controls: the main wallet plus linked
|
||||
// positions (Qualified Start). Credits pool across them on the dashboard.
|
||||
async function myMemberIds(s) {
|
||||
const main = await auth.refreshMemberId(s);
|
||||
const ids = main ? [main] : [];
|
||||
if (s && s.email) for (const p of await accounts.positions(s.email)) if (p.memberId && !ids.includes(p.memberId)) ids.push(p.memberId);
|
||||
return { main, ids };
|
||||
}
|
||||
function parseWallOffers(a) {
|
||||
try { const v = JSON.parse((a && a.wallOffers) || '[]'); return Array.isArray(v) ? v.slice(0, 2) : []; } catch (e) { return []; }
|
||||
}
|
||||
@@ -653,6 +661,15 @@ const server = http.createServer(async (req, res) => {
|
||||
let memberId = 0;
|
||||
try { memberId = await chain.memberIdByAccount(r.address); } catch (e) {}
|
||||
const s = await auth.fromRequest(req);
|
||||
if (s && s.email && b.asPosition) {
|
||||
// Qualified Start: a second (third…) wallet on the same account. It becomes
|
||||
// its own on-chain member under this member's id when it buys; the session
|
||||
// stays on the main wallet.
|
||||
const pr = await accounts.addPosition(s.email, r.address);
|
||||
if (pr.error) return json(res, 400, pr);
|
||||
if (memberId) await accounts.setPositionMember(r.address, memberId);
|
||||
return json(res, 200, { ok: true, position: true, address: r.address, memberId });
|
||||
}
|
||||
if (s && s.email) {
|
||||
const lr = await accounts.linkWallet(s.email, r.address);
|
||||
if (lr.error) return json(res, 400, lr);
|
||||
@@ -660,6 +677,8 @@ const server = http.createServer(async (req, res) => {
|
||||
return json(res, 200, { ok: true, linked: true, address: r.address, memberId });
|
||||
}
|
||||
const acct = await accounts.byAddress(r.address);
|
||||
if (!acct && await accounts.positionOwner(r.address))
|
||||
return json(res, 400, { error: 'That wallet is a linked position on an account. Sign in with that account\'s email instead.' });
|
||||
const token = await auth.mintSession({ email: acct ? acct.email : null, address: r.address, memberId });
|
||||
return json(res, 200, { ok: true, address: r.address, memberId },
|
||||
{ 'Set-Cookie': auth.sessionCookie(token) });
|
||||
@@ -696,7 +715,7 @@ const server = http.createServer(async (req, res) => {
|
||||
const mm = await chain.member(memberId);
|
||||
out.buyerCount = mm.buyerCount;
|
||||
out.onchainSponsorId = mm.sponsorId;
|
||||
out.credits = await chain.creditBalance(memberId, 0);
|
||||
out.credits = (await ads.pooledCredits((await myMemberIds(s)).ids)).total;
|
||||
} catch (e) { out.chainReadError = true; }
|
||||
}
|
||||
return json(res, 200, out);
|
||||
@@ -745,7 +764,7 @@ const server = http.createServer(async (req, res) => {
|
||||
try {
|
||||
const mm = await chain.member(memberId);
|
||||
out.buyerCount = mm.buyerCount;
|
||||
out.credits = await ads.availableCredits(memberId);
|
||||
out.credits = (await ads.pooledCredits((await myMemberIds(s)).ids)).total;
|
||||
} catch (e) { out.chainReadError = true; }
|
||||
let earned = 0n, n = 0;
|
||||
for (const ev of chain.recentEvents(600)) {
|
||||
@@ -784,6 +803,38 @@ const server = http.createServer(async (req, res) => {
|
||||
out.wallUnlocked = wallUnlockedFor(out.buyerCount || 0); // how many wall positions are the member's own
|
||||
return json(res, 200, out);
|
||||
}
|
||||
// -- linked positions (Qualified Start): list, refresh from chain, unlink
|
||||
if (p === '/api/my/positions' && req.method === 'GET') {
|
||||
const s = await auth.fromRequest(req);
|
||||
if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' });
|
||||
const acct = await accounts.byEmail(s.email);
|
||||
const mainId = await auth.refreshMemberId(s);
|
||||
const list = await accounts.positions(s.email);
|
||||
const out = [];
|
||||
for (const pos of list) {
|
||||
let id = pos.memberId;
|
||||
if (!id) { try { id = await chain.memberIdByAccount(pos.address); if (id) await accounts.setPositionMember(pos.address, id); } catch (e) {} }
|
||||
const row = { address: pos.address, memberId: id || 0, buyerCount: 0, counted: false, credits: 0, created: pos.created };
|
||||
if (id) {
|
||||
try { const mm = await chain.member(id); row.buyerCount = mm.buyerCount; row.counted = mm.countedAsBuyer; row.sponsorId = mm.sponsorId; } catch (e) {}
|
||||
try { row.credits = await ads.availableCredits(id); } catch (e) {}
|
||||
}
|
||||
out.push(row);
|
||||
}
|
||||
const main = { address: (acct && acct.address) || null, memberId: mainId, credits: 0, buyerCount: 0 };
|
||||
if (mainId) {
|
||||
try { main.credits = await ads.availableCredits(mainId); } catch (e) {}
|
||||
try { main.buyerCount = (await chain.member(mainId)).buyerCount; } catch (e) {}
|
||||
}
|
||||
return json(res, 200, { main, positions: out, totalCredits: main.credits + out.reduce((n, r) => n + r.credits, 0) });
|
||||
}
|
||||
if (p === '/api/my/positions/remove' && req.method === 'POST') {
|
||||
const s = await auth.fromRequest(req);
|
||||
if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' });
|
||||
const b = await readBody(req);
|
||||
const r = await accounts.removePosition(s.email, b.address);
|
||||
return json(res, r.error ? 400 : 200, r);
|
||||
}
|
||||
if (p === '/api/my/profile' && req.method === 'POST') {
|
||||
const s = await auth.fromRequest(req);
|
||||
if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' });
|
||||
@@ -898,6 +949,14 @@ const server = http.createServer(async (req, res) => {
|
||||
email: L.level === 1 ? m.email : null, // directs only
|
||||
joined: m.created,
|
||||
earnedWei: (m.memberId && earnedBy[m.memberId]) || '0' })) }));
|
||||
// the member's own linked positions sit on level 1 too, labelled as theirs
|
||||
const own = (await accounts.positions(s.email)).filter(p => p.memberId);
|
||||
if (own.length) {
|
||||
if (!out.find(L => L.level === 1)) out.unshift({ level: 1, members: [] });
|
||||
const L1 = out.find(L => L.level === 1);
|
||||
own.forEach((p, i) => L1.members.push({ memberId: p.memberId, name: 'You · position ' + (i + 2), own: true,
|
||||
email: null, joined: p.created, earnedWei: earnedBy[p.memberId] || '0' }));
|
||||
}
|
||||
return json(res, 200, { levels: out, counts: out.map(L => L.members.length) });
|
||||
}
|
||||
// -- broadcast a message to your downline (1/day), on-site inbox + email
|
||||
@@ -1386,7 +1445,10 @@ const server = http.createServer(async (req, res) => {
|
||||
if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' });
|
||||
const memberId = await auth.refreshMemberId(s);
|
||||
const out = { campaigns: await ads.listCampaigns(s.email), rates: ads.rates(), bannerSizes: ads.bannerSizes() };
|
||||
out.purchasedCredits = memberId ? await ads.availableCredits(memberId) : 0;
|
||||
const pool = await ads.pooledCredits((await myMemberIds(s)).ids);
|
||||
out.purchasedCredits = pool.total;
|
||||
out.largestPosition = pool.best.avail; // a single campaign budget has to fit one position
|
||||
out.positionCount = pool.per.length;
|
||||
out.earnedCredits = await ads.earnedBalance(s.email);
|
||||
out.availableCredits = out.purchasedCredits + out.earnedCredits;
|
||||
return json(res, 200, out);
|
||||
@@ -1400,7 +1462,15 @@ const server = http.createServer(async (req, res) => {
|
||||
const fc = await frameCheck(b.targetUrl);
|
||||
if (!fc.ok) return json(res, 400, { error: fc.reason });
|
||||
}
|
||||
const r = await ads.createCampaign(s.email, memberId, b);
|
||||
// charge the best-funded of the member's positions (main + Qualified Start
|
||||
// wallets). A campaign burns from one member id, so the budget must fit inside it.
|
||||
const pool = await ads.pooledCredits((await myMemberIds(s)).ids);
|
||||
const fundId = pool.best.memberId || memberId;
|
||||
const earnedNow = String(b.type) !== 'login' ? await ads.earnedBalance(s.email) : 0;
|
||||
const budget = Math.floor(Number(b.budget) || 0);
|
||||
if (pool.per.length > 1 && budget > pool.best.avail + earnedNow && budget <= pool.total + earnedNow)
|
||||
return json(res, 400, { error: 'Your credits are spread across ' + pool.per.length + ' positions and one campaign spends from one of them. The largest single position holds ' + pool.best.avail + ' credits: set the budget to that or less, or run two campaigns.' });
|
||||
const r = await ads.createCampaign(s.email, fundId, b);
|
||||
return json(res, r.error ? 400 : 200, r);
|
||||
}
|
||||
m = /^\/api\/my\/campaigns\/(\d+)\/topup$/.exec(p);
|
||||
|
||||
Reference in New Issue
Block a user