From 05afe5c5f64ace56fd57b763bef841cd465088db Mon Sep 17 00:00:00 2001 From: martbost Date: Sun, 30 Aug 2026 05:40:00 -0500 Subject: [PATCH] Admin: award Suite capacity to any member as a team bonus MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- public/admin.html | 69 +++++++++++++++++++++++++++++++++++++++++- public/suite-grants.js | 5 +-- server.js | 20 ++++++++++++ suite-grants.js | 40 +++++++++++++++++++++++- 4 files changed, 130 insertions(+), 4 deletions(-) diff --git a/public/admin.html b/public/admin.html index efce469..cdea6fa 100644 --- a/public/admin.html +++ b/public/admin.html @@ -1,5 +1,28 @@ RM Circle Team Build Admin +
+

Award Suite Capacity

+

+ Give any registered position extra Suite capacity as a team bonus. It sits on top of whatever their level + already allows, expires with the month like every other allowance, and shows on their side as coming from + “the team”. Nothing is deducted from anyone — this is issued, not transferred. +

+
+ + + + + +
+
+
+
Bonuses awarded this month
+
+ +
ToWhatAmountReasonWhen
+
+
+ -
+
+ diff --git a/public/suite-grants.js b/public/suite-grants.js index d8d7c00..a7982b7 100644 --- a/public/suite-grants.js +++ b/public/suite-grants.js @@ -23,13 +23,14 @@ if (!keys.length) { $('gGot').innerHTML = ''; return; } var parts = keys.map(function (k) { var r = received[k]; - var who = r.from.map(function (f) { return '#' + f.by; }); + // by:0 is an admin bonus from the team, not a member grant. + var who = r.from.map(function (f) { return Number(f.by) === 0 ? 'the team' : '#' + f.by; }); var uniq = who.filter(function (v, i) { return who.indexOf(v) === i; }); var t = (state.status && state.status.tools[k]) || {}; return '
· ' + n(r.total) + ' ' + esc(t.unit || k) + ' of ' + esc((t.label || k).toLowerCase()) + ', from ' + uniq.join(', ') + '
'; }); - $('gGot').innerHTML = '
Someone above you topped you up this month.' + + $('gGot').innerHTML = '
You have been topped up this month.' + parts.join('') + '
' + 'It is already added to your allowances — nothing to claim.
'; } diff --git a/server.js b/server.js index f2d972a..d20a849 100644 --- a/server.js +++ b/server.js @@ -1356,6 +1356,26 @@ async function handleApi(req,res,pathname){ const s=getSession(req);if(s){sessions.delete(s.token);saveSessions();}return json(res,200,{ok:true},{'Set-Cookie':'ctb.sid=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0'}); } if(pathname.startsWith('/api/admin/')&&!requireAdmin(req,res))return; + if(pathname==='/api/admin/grant'){ + if(req.method==='GET'){ + return json(res,200,{tools:suiteGrants.tools(),log:suiteGrants.adminLog(60)}); + } + if(req.method==='POST'){ + const b=await bodyJson(req)||{}; + const to=Number(b.to)||0; + if(!to)return json(res,400,{error:'Enter the position number.'}); + // Confirm the position actually exists before crediting it — a typo would + // otherwise sit in the ledger crediting nobody. + let md=null; + try{ md=await chain.memberPublic(to); }catch(err){} + if(!md||!md.registered)return json(res,404,{error:'Position #'+to+' is not registered on-chain.'}); + try{ + const rec=suiteGrants.adminGrant({to:to,tool:String(b.tool||''),n:b.n,note:b.note}); + return json(res,200,{granted:rec,levelName:md.levelName,log:suiteGrants.adminLog(60)}); + }catch(err){ return json(res,400,{error:String(err.message||err)}); } + } + } + if(req.method==='GET'&&pathname==='/api/admin/matrix-tree'){ return json(res,200,chain.getMatrixTree()); } diff --git a/suite-grants.js b/suite-grants.js index f06b1d4..d8a7d7f 100644 --- a/suite-grants.js +++ b/suite-grants.js @@ -112,6 +112,44 @@ function status(memberId, level, preview) { }; } +// 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. @@ -148,4 +186,4 @@ function grant(opts) { return rec; } -module.exports = { init, MIN_LEVEL, POOLS, tools, poolFor, status, grant, receivedBy, givenBy, receivedDetail }; +module.exports = { init, MIN_LEVEL, POOLS, tools, poolFor, status, grant, adminGrant, adminLog, receivedBy, givenBy, receivedDetail };