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
The RM CircleRM CircleSponsor Router Admin

Team Admin

Manage the current sponsor, qualification queue, and onboarding settings.

+
+

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 };