Files
rm-circle-team-router/suite-grants.js
T
martbost 05afe5c5f6 Admin: award Suite capacity to any member as a team bonus
Marty asked for a way to grant impressions to anybody, labelled as an admin
bonus. Team Grants already did this for leaders, but only within their own
organisation and only out of a level pool.

adminGrant() is deliberately a different thing: it comes from no pool (the
team is not a position and has no allowance), it can reach ANY registered
position rather than only someone downline, and it carries a reason that
survives in the ledger. Recorded with by:0 so nothing downstream mistakes it
for a leader's grant — the member's own page renders those as "the team"
rather than "#0".

The route verifies the position exists on-chain before crediting it; a typo
would otherwise sit in the ledger crediting nobody, and it would be invisible
until someone went looking.

Awards land on top of whatever the level already allows and expire with the
month like every other allowance. The admin page lists what has been awarded
this month, so bonuses stay auditable rather than vanishing once given.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 05:40:00 -05:00

190 lines
8.0 KiB
JavaScript

// Circle Suite — Team Grants (Vertex L7, Corona L8).
//
// The problem this fixes: every tool from L1 to L8 was a "me" tool — help ME
// write, help ME post, help ME measure. But past the first couple of levels the
// business is not about you. It is get two, help your two, teach them to teach.
// So the top of the ladder was scaling the wrong axis: more of your own
// capability, at double the price each rung.
//
// A grant lets a leader hand Suite capacity DOWN into their own organisation —
// impressions, generations, page builds, ad batches. Three things make it work:
//
// 1. It comes from a POOL that is a perk of the level, not a tax on the
// leader's own allowance. Making leaders choose between equipping their
// team and using their own tools would just mean nobody grants anything.
// 2. It can only go to someone inside the grantor's own organisation, checked
// against the contract. This is not a way to hand capacity to strangers.
// 3. It expires with the month, like every other allowance here. Grants are
// for unblocking someone now, not for building up a balance.
'use strict';
const fs = require('fs');
const path = require('path');
let DATA_DIR = null;
function init(opts) { DATA_DIR = opts.dataDir; }
function file() { return path.join(DATA_DIR, 'team-grants.json'); }
function readAll() { try { return JSON.parse(fs.readFileSync(file(), 'utf8')); } catch (e) { return {}; } }
function writeAll(v) { try { fs.writeFileSync(file(), JSON.stringify(v)); } catch (e) {} }
function monthKey() { const d = new Date(); return d.getUTCFullYear() + '-' + String(d.getUTCMonth() + 1).padStart(2, '0'); }
const MIN_LEVEL = 7;
// What a leader can give away each month, by level (index = level - 1).
// Zero below Vertex — this is the thing the top of the ladder buys.
const POOLS = {
traffic: { label: 'Ad impressions', unit: 'impressions', step: 1000, max: 50000,
pool: [0, 0, 0, 0, 0, 0, 60000, 200000] },
copy: { label: 'Copy Engine generations', unit: 'generations', step: 25, max: 300,
pool: [0, 0, 0, 0, 0, 0, 300, 1000] },
page: { label: 'Page builds', unit: 'pages', step: 1, max: 20,
pool: [0, 0, 0, 0, 0, 0, 15, 50] },
textad: { label: 'Text ad batches', unit: 'batches', step: 5, max: 60,
pool: [0, 0, 0, 0, 0, 0, 60, 200] }
};
function tools() {
return Object.keys(POOLS).map(function (k) {
return { key: k, label: POOLS[k].label, unit: POOLS[k].unit, step: POOLS[k].step, max: POOLS[k].max };
});
}
// A tester on a level override can SEE every tier, but must not be able to
// hand out a real Corona pool — 200,000 network impressions given to real
// members is real inventory, spent for real. Preview pools are big enough to
// exercise the whole flow and small enough that losing them costs nothing.
const PREVIEW_POOL = { traffic: 5000, copy: 25, page: 2, textad: 5 };
function poolFor(tool, level, preview) {
const t = POOLS[tool];
if (!t) return 0;
const lv = Math.max(1, Math.min(8, Number(level) || 1));
const base = t.pool[lv - 1] || 0;
if (preview) return Math.min(base, PREVIEW_POOL[tool] || 0);
return base;
}
function rows(mk) {
const all = readAll();
return all[mk || monthKey()] || [];
}
// How much of `tool` this position has RECEIVED this month.
function receivedBy(memberId, tool) {
return rows().reduce(function (s, r) {
return s + ((Number(r.to) === Number(memberId) && r.tool === tool) ? Number(r.n) || 0 : 0);
}, 0);
}
// How much of `tool` this position has GIVEN AWAY this month.
function givenBy(memberId, tool) {
return rows().reduce(function (s, r) {
return s + ((Number(r.by) === Number(memberId) && r.tool === tool) ? Number(r.n) || 0 : 0);
}, 0);
}
// Who gave it to them — so a recipient can see it came from their upline and
// not from nowhere. That is half the point: it should feel like being helped.
function receivedDetail(memberId) {
const out = {};
rows().forEach(function (r) {
if (Number(r.to) !== Number(memberId)) return;
if (!out[r.tool]) out[r.tool] = { total: 0, from: [] };
out[r.tool].total += Number(r.n) || 0;
out[r.tool].from.push({ by: Number(r.by), n: Number(r.n), at: r.at });
});
return out;
}
function status(memberId, level, preview) {
const t = {};
Object.keys(POOLS).forEach(function (k) {
const pool = poolFor(k, level, preview);
const given = givenBy(memberId, k);
t[k] = {
label: POOLS[k].label, unit: POOLS[k].unit, step: POOLS[k].step, max: POOLS[k].max,
pool: pool, given: given, remaining: Math.max(0, pool - given)
};
});
return {
level: Number(level) || 0, canGrant: Number(level) >= MIN_LEVEL, preview: !!preview,
tools: t, resets: monthKey(),
history: rows().filter(function (r) { return Number(r.by) === Number(memberId); }).slice(-40).reverse(),
received: receivedDetail(memberId)
};
}
// An admin bonus. Distinct from a member grant in three ways: it comes from no
// pool (the team is not a position and has no allowance), it can go to ANY
// member rather than only someone in the granter's own organisation, and it
// carries a note so the reason survives in the ledger. Recorded with by:0 so
// nothing downstream mistakes it for a leader's grant.
function adminGrant(opts) {
const to = Number(opts.to);
const tool = String(opts.tool || '');
const n = Math.floor(Number(opts.n) || 0);
if (!POOLS[tool]) throw new Error('That is not something that can be granted.');
if (!to) throw new Error('Which position is this for?');
if (n <= 0) throw new Error('How much?');
if (n > 1000000) throw new Error('That is more than anyone could use in a month.');
const all = readAll();
const mk = monthKey();
if (!all[mk]) all[mk] = [];
const rec = {
by: 0, to: to, tool: tool, n: n,
admin: true,
note: String(opts.note || '').replace(/\s+/g, ' ').trim().slice(0, 80) || 'Team bonus',
at: new Date().toISOString()
};
all[mk].push(rec);
const keep = Object.keys(all).sort().slice(-3);
const trimmed = {};
keep.forEach(function (k) { trimmed[k] = all[k]; });
writeAll(trimmed);
return rec;
}
// Everything the team has handed out this month, newest first — for the admin
// view, so bonuses are auditable rather than invisible once given.
function adminLog(limit) {
return rows().filter(function (r) { return r.admin; })
.slice(-(Math.max(1, Math.min(200, Number(limit) || 50)))).reverse();
}
// Hand capacity to someone. The caller is responsible for having verified that
// `to` is inside `by`'s organisation — that check needs the chain and lives in
// the route, not here.
function grant(opts) {
const by = Number(opts.by), to = Number(opts.to);
const tool = String(opts.tool || '');
const n = Math.floor(Number(opts.n) || 0);
const level = Number(opts.level) || 0;
if (!POOLS[tool]) throw new Error('That is not something you can grant.');
if (level < MIN_LEVEL) throw new Error('Team Grants unlock at Vertex (level 7).');
if (!to || to === by) throw new Error('Pick someone in your team other than yourself.');
if (n <= 0) throw new Error('Choose how much to give.');
if (n > POOLS[tool].max) throw new Error('The most you can give one person at a time is ' + POOLS[tool].max.toLocaleString() + '.');
const pool = poolFor(tool, level, opts.preview);
const given = givenBy(by, tool);
if (given + n > pool) {
throw new Error('That is more than you have left to give this month — ' +
Math.max(0, pool - given).toLocaleString() + ' ' + POOLS[tool].unit + ' remaining.');
}
const all = readAll();
const mk = monthKey();
if (!all[mk]) all[mk] = [];
const rec = { by: by, to: to, tool: tool, n: n, at: new Date().toISOString() };
all[mk].push(rec);
// Keep three months, same as every other ledger here.
const keep = Object.keys(all).sort().slice(-3);
const trimmed = {};
keep.forEach(function (k) { trimmed[k] = all[k]; });
writeAll(trimmed);
return rec;
}
module.exports = { init, MIN_LEVEL, POOLS, tools, poolFor, status, grant, adminGrant, adminLog, receivedBy, givenBy, receivedDetail };