A federation needs membership totals from its clubs, with the same reporting date and a clear indication of missing data. This guide builds a private dashboard that reads each authorized campai organization separately, then saves one complete report.
Use an external API integration because your reporting server connects to several organizations with their own keys. Use the board membership Developer App when staff need a report inside one campai organization. An organization’s Developer App permissions do not grant access to another club.
What you’ll have at the end
A complete dated membership report across separately authorized clubs, with a copyable board summary and preserved results on failure.
- Each club’s separate key
- Complete member pages
- Shared reporting date
- Saved aggregate report
Before you start
- Node.js 22+ and a private working directory
- Two authorized organizations with selected synthetic members
- One restricted Mandates Read and Members Read key per organization
On this page
Ask each participating club to authorize the federation’s use of its data. Choose one mandate per organization and a shared report date. Start with two test organizations and explicitly selected synthetic members; do not use real member data for screenshots.
Each organization needs its own API key with Mandates Read and Members Read, restricted to its selected mandate. Confirm that the membership entry and exit dates are readable. Follow API permissions and Find organization and mandate IDs.
The sample supports 2–10 distinct organizations and up to 5,000 members per organization. It counts memberships: a person belonging to two clubs contributes one membership to each. It does not match people across organizations by email or name.
Download the complete example into a private directory. It uses Node.js 22 or newer and requires no packages. Edit the organizations array in server.mjs:
| Setting | Value to use |
|---|---|
label |
The club name you want displayed in the report. |
organization |
This club’s full 24-character organization ID. |
mandate |
The authorized mandate in that organization. |
apiKey |
The key created by that organization. Keep it on your server. |
testContactIds |
The exact synthetic member IDs for the first test. |
Use a nonempty test selection for both organizations. The program rejects mixed scopes where one club has selected test members and another includes its entire membership.
For a repeatable exercise, create two synthetic members per organization. Give both an entry date before your test date, then give one an exit date equal to the test date. On that date both memberships count as active. On the following day only the member without an exit date remains active. Avoid adding subscriptions, debtors or notifications to these fixtures.
Run node server.mjs and open http://127.0.0.1:9391/. Keep the private directory outside your website’s public files.
Choose your test date and select Read all organizations. The server requests each organization with its own key and checks every page before saving the combined report. The browser receives only the projected totals; names, IDs, addresses and API keys remain out of the report.
Compare the organization rows with your test members. Check Active memberships, Organizations included and Dates to review. The saved report displays both its report date and its scan completion time.
The entry and exit dates are inclusive. A missing entry date appears as a missing start, not an assumed active membership. An exit date before an entry date appears as an inconsistent interval. These records stay in the scanned total and the review count.
Choose the following day and run another report. In the two-members-per-club exercise, the active total should fall from four to two. These calculations use the current membership date fields; they cannot reconstruct all historical joins, departures or corrections.
Revoke only one of your disposable test keys and choose another report date. Select Read all organizations again. The failed organization should appear in Read status, and the error should explain that the previous complete report remains unchanged.
Check the date beside the saved totals. It must still be the last successful report date, even though the attempted date is different. The program does not silently replace a missing club’s count with zero or add fresh counts to older counts.
The same rule applies to a failed later page, changed result count, duplicate IDs, missing date fields or an unexpected mandate. A scan reads pages sequentially, so it is not an atomic snapshot across clubs. Run during a quiet period and rerun after relevant source changes.
Restart the server and select Load saved report. It reads the saved local aggregate file without calling campai. This also checks the outcome if your browser lost the response to a successful scan.
Select Copy board summary and paste it into a private note. The text includes each club’s active and scanned counts, data gaps, the report date and the completed scan time. The selectable text box remains available if browser clipboard access is blocked.
Review who should receive it. Counts for small clubs can still be sensitive even though the report excludes individual records. A failed scan leaves the previous summary visible with its original date.
This is one page from one organization. Use its own key and mandate. Continue with offsets 100, 200 and so on until the final short page, then compare the number read with the reported count. The complete program also checks for count changes and duplicate or out-of-order IDs.
Example
curl --fail-with-body --silent --show-error --max-time 30 --globoff \
--request POST 'https://cloud.campai.com/api/YOUR_ORGANIZATION_ID/YOUR_MANDATE_ID/crm/contacts/list' \
--header 'X-API-Key: YOUR_API_KEY' \
--header 'Content-Type: application/json' \
--data-raw '{
"types": [
"member"
],
"sort": {
"_id": "asc"
},
"limit": 100,
"offset": 0,
"returnCount": true
}'For test fixtures, add selection: { ids: [...] } with that organization’s synthetic IDs. The complete source applies this selection at the API request, rather than downloading unrelated members and filtering them afterward.
The saved report contains asOf, completedAt, per-organization rows, and combined totals. Map those aggregate fields to your reporting database or board dashboard. Keep organization ownership and both dates alongside each result.
| Field | Meaning |
|---|---|
active |
Entry date on or before the report day, and no earlier exit date. |
ended |
A valid interval whose exit date is before the report day. |
future |
A valid interval whose entry date is after the report day. |
unknownStart |
A member whose entry date is null. |
inconsistent |
A member whose exit date is before their entry date. |
scanned |
All reviewed member records, including gaps. |
After the synthetic test, remove all test selections only if every organization has authorized a complete mandate scan. Preserve the old report separately and start a new state file for this deliberate scope change. The sample refuses to reuse a saved report when an organization, mandate, label or selection changes. Rotating a key within the same authorized scope is allowed.
The loopback example assumes one trusted operator. Shared hosting requires staff authentication, HTTPS, access controls, private storage and an agreed retention policy. Keep individual API responses out of logs and exported reports. No federation system is connected automatically by this example.
Revoke the remaining disposable key, delete the synthetic members and stop the server after testing. The reporting code itself performs only reads in campai.
The complete federation membership dashboard
Configure one read-only key per authorized organization, compare memberships on one date and retain the last complete report on failure. No packages required.
Download ZIPView and copy the complete source
The complete federation membership dashboard
import { createServer } from 'node:http';
import { createHash, randomBytes } from 'node:crypto';
import { open, readFile, rename, unlink } from 'node:fs/promises';
import { resolve } from 'node:path';
// One separately authorized key and mandate per organization. Keep this file private.
const organizations = [
{ label: 'Northside club', organization: 'YOUR_FIRST_ORGANIZATION_ID', mandate: 'YOUR_FIRST_MANDATE_ID', apiKey: 'YOUR_FIRST_API_KEY', testContactIds: [] },
{ label: 'Riverside club', organization: 'YOUR_SECOND_ORGANIZATION_ID', mandate: 'YOUR_SECOND_MANDATE_ID', apiKey: 'YOUR_SECOND_API_KEY', testContactIds: [] },
];
const apiBase = 'https://cloud.campai.com/api';
const port = 9391;
const pageSize = 100;
const maxMembersPerOrganization = 5000;
const origin = `http://127.0.0.1:${port}`;
const hash = value => createHash('sha256').update(JSON.stringify(value)).digest('hex');
const validId = value => typeof value === 'string' && /^[a-f0-9]{24}$/.test(value);
const fault = (message, status = 400) => Object.assign(new Error(message), { status });
if (!Array.isArray(organizations) || organizations.length < 2 || organizations.length > 10) throw Error('Configure 2–10 separately authorized organizations.');
const seenOrganizations = new Set(), labels = new Set();
for (const item of organizations) {
if (!validId(item.organization) || !validId(item.mandate) || typeof item.apiKey !== 'string' || item.apiKey.includes('YOUR_') || !item.apiKey || /\s/.test(item.apiKey)) throw Error('Configure each organization, mandate and private API key.');
if (typeof item.label !== 'string' || !item.label.trim() || item.label.length > 80 || labels.has(item.label) || seenOrganizations.has(item.organization)) throw Error('Use a unique label and a distinct organization for each row.');
if (!Array.isArray(item.testContactIds) || item.testContactIds.length > 1000 || item.testContactIds.some(x => !validId(x)) || new Set(item.testContactIds).size !== item.testContactIds.length) throw Error('Invalid synthetic member selection.');
seenOrganizations.add(item.organization); labels.add(item.label);
}
if (organizations.some(x => x.testContactIds.length) && organizations.some(x => !x.testContactIds.length)) throw Error('Use synthetic selections for every organization, or complete scans for every organization.');
const scopes = organizations.map(({ label, organization, mandate, testContactIds }) => ({ label, organization, mandate, testContactIds }));
const scopeHash = hash(scopes), csrf = randomBytes(24).toString('hex');
const statePath = resolve('federation-dashboard-state.json');
let state = { version: 1, scopeHash, revision: 0, report: null };
let lastAttempt = null;
const lock = await open(statePath + '.lock', 'wx', 0o600);
try {
const saved = JSON.parse(await readFile(statePath, 'utf8'));
if (saved.version !== 1 || saved.scopeHash !== scopeHash || !Number.isSafeInteger(saved.revision) || saved.revision < 0 || (saved.report !== null && saved.reportHash !== hash(saved.report))) throw Error('Saved report or account scope changed. Keep the previous file and review the configuration.');
state = saved;
} catch (error) { if (error.code !== 'ENOENT') { await lock.close(); await unlink(statePath + '.lock'); throw error; } }
let pending = Promise.resolve();
function exclusive(action) { const result = pending.then(action); pending = result.catch(() => {}); return result; }
async function save(report) {
const next = { version: 1, scopeHash, revision: state.revision + 1, report, reportHash: hash(report) };
const temporary = statePath + '.' + process.pid + '.tmp', file = await open(temporary, 'wx', 0o600);
try { await file.writeFile(JSON.stringify(next, null, 2)); await file.sync(); } finally { await file.close(); }
await rename(temporary, statePath); state = next;
}
function day(value) {
if (typeof value !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(value) || !Number.isFinite(Date.parse(value + 'T00:00:00Z')) || new Date(value + 'T00:00:00Z').toISOString().slice(0, 10) !== value || value < '1900-01-01' || value > '2100-12-31') throw fault('Choose a real report date between 1900 and 2100.');
return value;
}
function storedDay(value) {
if (value === null) return null;
if (typeof value !== 'string') throw Error('Membership date fields are unavailable.');
if (/^\d{4}-\d{2}-\d{2}T00:00:00\.000Z$/.test(value)) return day(value.slice(0, 10));
return day(value); // Date-only values stay date-only; never truncate a non-midnight instant.
}
async function readPage(item, offset, deadline) {
const remaining = deadline - Date.now(); if (remaining <= 0) throw Error('The complete scan exceeded two minutes.');
const response = await fetch(`${apiBase}/${item.organization}/${item.mandate}/crm/contacts/list`, {
method: 'POST', headers: { 'X-API-Key': item.apiKey, 'Content-Type': 'application/json' },
body: JSON.stringify({ types: ['member'], sort: { _id: 'asc' }, limit: pageSize, offset, returnCount: true, ...(item.testContactIds.length ? { selection: { ids: item.testContactIds } } : {}) }),
redirect: 'error', signal: AbortSignal.timeout(Math.min(15000, remaining)),
});
if (!response.ok || !response.body) throw Error(`campai HTTP ${response.status}.`);
const chunks = []; let size = 0; const reader = response.body.getReader();
try { while (true) { const { done, value } = await reader.read(); if (done) break; size += value.length; if (size > 8 * 1024 * 1024) { await reader.cancel(); throw Error('Member page exceeded the response limit.'); } chunks.push(value); } } finally { reader.releaseLock(); }
try { return JSON.parse(Buffer.concat(chunks).toString('utf8')); }
catch { throw Error('The member response was not valid JSON.'); }
}
async function summarize(item, asOf, deadline) {
const counts = { scanned: 0, active: 0, ended: 0, future: 0, unknownStart: 0, inconsistent: 0 };
const seen = new Set(); let expectedCount = null, previousId = null, complete = false;
for (let offset = 0; offset <= maxMembersPerOrganization; offset += pageSize) {
const page = await readPage(item, offset, deadline);
if (!page || !Array.isArray(page.contacts) || page.contacts.length > pageSize || !Number.isSafeInteger(page.count) || page.count < 0 || page.count > maxMembersPerOrganization) throw Error('Invalid or oversized member-list response.');
if (expectedCount === null) expectedCount = page.count;
if (page.count !== expectedCount) throw Error('Member count changed during scanning. Run a fresh report.');
for (const contact of page.contacts) {
if (!contact || !validId(contact._id) || (!Array.isArray(contact.mandates) || !contact.mandates.includes(item.mandate)) || (contact.organization !== undefined && contact.organization !== item.organization) || !Array.isArray(contact.types) || !contact.types.includes('member') || !contact.member || !Object.hasOwn(contact.member, 'entryAt') || !Object.hasOwn(contact.member, 'exitAt')) throw Error('Member scope or required fields do not match.');
if (seen.has(contact._id) || (previousId !== null && contact._id <= previousId)) throw Error('The member list moved during scanning. Run a fresh report.');
if (item.testContactIds.length && !item.testContactIds.includes(contact._id)) throw Error('A member is outside the synthetic selection.');
seen.add(contact._id); previousId = contact._id;
const entry = storedDay(contact.member.entryAt), exit = storedDay(contact.member.exitAt);
counts.scanned++;
if (counts.scanned > maxMembersPerOrganization) throw Error('The member limit was exceeded.');
if (entry === null) counts.unknownStart++;
else if (exit !== null && exit < entry) counts.inconsistent++;
else if (entry > asOf) counts.future++;
else if (exit !== null && exit < asOf) counts.ended++;
else counts.active++; // Both entry and exit days are included.
}
if (page.contacts.length < pageSize) { complete = true; break; }
}
if (!complete || counts.scanned !== expectedCount || (item.testContactIds.length && counts.scanned !== item.testContactIds.length)) throw Error('The organization scan is incomplete.');
return { label: item.label, ...counts, checkedAt: new Date().toISOString() };
}
async function scan(input) {
const asOf = day(input.asOf), started = Date.now(), rows = [];
lastAttempt = { asOf, startedAt: new Date(started).toISOString(), complete: false, organizations: [] };
for (const item of organizations) {
try { rows.push(await summarize(item, asOf, started + 120000)); lastAttempt.organizations.push({ label: item.label, status: 'Read successfully' }); }
catch (error) {
// Never return an API response body, key, or member details to the browser.
lastAttempt.organizations.push({ label: item.label, status: 'Read failed' });
throw fault(`${item.label}: ${error.message} The previous complete report is unchanged.`, 502);
}
}
if (Date.now() - started > 120000) throw fault('The scan took too long. The previous complete report is unchanged.', 502);
const totals = Object.fromEntries(['scanned', 'active', 'ended', 'future', 'unknownStart', 'inconsistent'].map(key => [key, rows.reduce((sum, row) => sum + row[key], 0)]));
await save({ asOf, startedAt: new Date(started).toISOString(), completedAt: new Date().toISOString(), fixtureOnly: organizations[0].testContactIds.length > 0, rows, totals });
lastAttempt.complete = true; return view();
}
function view() { return { revision: state.revision, report: state.report, lastAttempt, labels: organizations.map(x => x.label) }; }
function json(res, status, data) { res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' }); res.end(JSON.stringify(data)); }
async function body(req) {
if (req.headers['content-type']?.split(';')[0] !== 'application/json') throw fault('Use JSON.', 415);
const chunks = []; let size = 0;
for await (const chunk of req) { size += chunk.length; if (size > 2000) throw fault('Request too large.', 413); chunks.push(chunk); }
try { return JSON.parse(Buffer.concat(chunks).toString('utf8')); } catch { throw fault('Invalid JSON.'); }
}
const server = createServer(async (req, res) => {
res.setHeader('Cache-Control', 'no-store'); res.setHeader('X-Robots-Tag', 'noindex, nofollow'); res.setHeader('X-Content-Type-Options', 'nosniff'); res.setHeader('Referrer-Policy', 'no-referrer');
try {
if (req.headers.host !== `127.0.0.1:${port}`) throw fault('Use the configured local address.', 403);
if (req.method === 'GET' && req.url === '/') {
const nonce = randomBytes(18).toString('base64');
res.setHeader('Content-Security-Policy', `default-src 'none'; script-src 'nonce-${nonce}'; style-src 'nonce-${nonce}'; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'`);
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); res.end(page(nonce)); return;
}
if (req.method === 'GET' && req.url === '/api/state') { json(res, 200, view()); return; }
if (req.method !== 'POST' || req.url !== '/api/report' || req.headers.origin !== origin || req.headers['x-csrf-token'] !== csrf) throw fault('Request rejected.', 403);
const input = await body(req);
const result = await exclusive(async () => { if (!input || input.revision !== state.revision) throw fault('Refresh before running another report.', 409); return scan(input); });
json(res, 200, result);
} catch (error) { json(res, error.status || 502, { error: error.message || 'Report failed. The prior report is unchanged.', ...view() }); }
});
server.listen(port, '127.0.0.1', () => console.log(`Federation dashboard: ${origin}`));
async function stop() { server.close(); server.closeAllConnections(); await pending; await lock.close(); await unlink(statePath + '.lock'); process.exit(0); }
process.once('SIGTERM', stop); process.once('SIGINT', stop);
function page(nonce) {
return `<!doctype html><html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Federation membership dashboard</title><style nonce="${nonce}">
/* Standard Tailwind palette, embedded so this example has no dependencies. */
:root{--slate-50:oklch(98.4% 0.003 247.858);--slate-100:oklch(96.8% 0.007 247.896);--slate-200:oklch(92.9% 0.013 255.508);--slate-300:oklch(86.9% 0.022 252.894);--slate-600:oklch(44.6% 0.043 257.281);--slate-900:oklch(20.8% 0.042 265.755);--violet-50:oklch(96.9% 0.016 293.756);--violet-200:oklch(89.4% 0.057 293.283);--violet-500:oklch(60.6% 0.25 292.717);--violet-800:oklch(43.2% 0.232 292.759);--emerald-50:oklch(97.9% 0.021 166.113);--emerald-800:oklch(43.2% 0.095 166.913);--amber-50:oklch(98.7% 0.022 95.277);--amber-800:oklch(47.3% 0.137 46.201);--white:#fff;--black:#000}*{box-sizing:border-box}body{margin:0;background:var(--slate-50);color:var(--slate-900);font:15px/1.55 system-ui,sans-serif}header{background:var(--white);border-bottom:1px solid var(--slate-200);padding:24px max(24px,calc((100% - 1080px)/2))}header strong{font-size:19px}main{max-width:1128px;margin:auto;padding:38px 24px 50px}h1{font-size:35px;letter-spacing:-1px;line-height:1.2;margin:0 0 12px}h2{font-size:19px;margin:0 0 18px}p{margin:0 0 20px;color:var(--slate-600)}.controls{display:flex;gap:12px;align-items:end;flex-wrap:wrap;margin:26px 0}label{display:grid;gap:6px;font-weight:600;font-size:13px}input,button,textarea{font:inherit}input,button{border:1px solid var(--slate-300);border-radius:10px;padding:11px 15px;background:var(--white);color:var(--slate-900)}button{cursor:pointer;font-weight:600}button.primary{background:var(--black);border-color:var(--black);color:var(--white)}button:disabled{opacity:.45;cursor:default}button:focus-visible,input:focus-visible,textarea:focus-visible{outline:3px solid var(--violet-500);outline-offset:3px}.stats{display:grid;grid-template-columns:repeat(3,1fr);gap:16px;margin:22px 0}.stat,.panel{background:var(--white);border:1px solid var(--slate-200);border-radius:18px;padding:24px}.stat strong{display:block;font-size:36px;line-height:1.2;margin-top:8px}.stat:first-child{background:var(--violet-50);border-color:var(--violet-200)}.stat:first-child strong{color:var(--violet-800)}.muted,small{color:var(--slate-600)}.layout{display:grid;grid-template-columns:1.5fr 1fr;gap:20px}.club+.club{border-top:1px solid var(--slate-200);padding-top:22px;margin-top:22px}.row{display:flex;justify-content:space-between;gap:15px}.bar{height:9px;border-radius:8px;background:var(--slate-100);margin:12px 0}.bar span{display:block;height:100%;background:var(--violet-500);border-radius:8px}.facts{font-size:13px;color:var(--slate-600)}.status{padding:14px 18px;border-radius:12px;background:var(--emerald-50);color:var(--emerald-800);margin-bottom:20px}.status.error{background:var(--amber-50);color:var(--amber-800)}.check{padding:14px 0;border-bottom:1px solid var(--slate-100)}textarea{width:100%;min-height:190px;border:1px solid var(--slate-300);border-radius:10px;padding:12px;margin-top:14px;font-size:13px}.summary-panel{margin-top:20px}.foot{font-size:13px;margin:22px 0 0}#copyStatus{font-size:13px;margin:10px 0}.empty{padding:25px 0;color:var(--slate-600)}@media(max-width:700px){h1{font-size:28px}.layout{grid-template-columns:1fr}.stats{gap:8px}.stat{padding:15px}.stat strong{font-size:27px}main{padding:26px 16px}.controls button{flex:1}}
</style><header><strong>campai / Federation report</strong></header><main><h1>One report. Each club accounted for.</h1><p>Read each authorized organization separately, then compare a complete membership snapshot.</p><div class="controls"><label>Report date<input id="date" type="date" required></label><button class="primary" id="run">Read all organizations</button><button id="refresh">Load saved report</button></div><div id="status" class="status" role="status">Ready to read the configured organizations.</div><div class="stats"><div class="stat">Active memberships<strong id="active">—</strong></div><div class="stat">Organizations included<strong id="clubs">—</strong></div><div class="stat">Dates to review<strong id="gaps">—</strong></div></div><div class="layout"><section class="panel"><h2>Memberships by organization</h2><p id="stamp">No complete report saved yet.</p><div id="rows"></div></section><section class="panel"><h2>Read status</h2><p>Every configured organization must succeed before the saved report is replaced.</p><div id="attempt"></div><p class="foot">Counts represent memberships. A person belonging to two clubs appears in both.</p></section></div><section class="panel summary-panel"><h2>Share the board summary</h2><p>The summary includes totals, the report date and the scan time. It contains no member names or contact details.</p><button id="copy" disabled>Copy board summary</button><div id="copyStatus" role="status"></div><textarea id="summary" aria-label="Board summary" readonly placeholder="Run a complete report first."></textarea></section><p class="foot">Current membership dates do not reconstruct historical events. Report gaps remain visible; failed scans preserve the previous report and its date.</p></main><script nonce="${nonce}">
const csrf=${JSON.stringify(csrf)};let state=null;const el=id=>document.getElementById(id);el('date').value=new Date().toLocaleDateString('en-CA');
const format=value=>new Intl.NumberFormat('en').format(value);const dateTime=value=>new Date(value).toLocaleString('en-GB',{timeZone:'UTC'})+' UTC';
function text(tag,value,className){const n=document.createElement(tag);n.textContent=value;if(className)n.className=className;return n;}
function render(next){state=next;const r=state.report;el('active').textContent=r?format(r.totals.active):'—';el('clubs').textContent=r?format(r.rows.length):'—';el('gaps').textContent=r?format(r.totals.unknownStart+r.totals.inconsistent):'—';el('stamp').textContent=r?'Report date '+r.asOf+' · Completed '+dateTime(r.completedAt)+(r.fixtureOnly?' · Selected test members':''):'No complete report saved yet.';el('rows').replaceChildren();
if(r){for(const row of r.rows){const box=text('div','','club'),line=text('div','','row');line.append(text('strong',row.label),text('strong',format(row.active)+' active'));const bar=text('div','','bar'),fill=text('span','');fill.style.width=(row.scanned?row.active/row.scanned*100:0)+'%';bar.append(fill);box.append(line,bar,text('div',format(row.scanned)+' scanned · '+row.ended+' ended · '+row.future+' future','facts'),text('div',row.unknownStart+' missing starts · '+row.inconsistent+' inconsistent intervals','facts'));el('rows').append(box);}}else el('rows').append(text('p','Your first complete read will appear here.','empty'));
el('attempt').replaceChildren();for(const label of state.labels){const result=state.lastAttempt?.organizations.find(x=>x.label===label);el('attempt').append(text('div',label+' · '+(result?.status||(state.lastAttempt?'Not read':'Awaiting read')),'check'));}
el('summary').value=r?'Membership report for '+r.asOf+'\\nCompleted '+dateTime(r.completedAt)+'\\n'+r.rows.map(x=>x.label+': '+x.active+' active / '+x.scanned+' scanned; '+(x.unknownStart+x.inconsistent)+' dates to review').join('\\n')+'\\nTotal: '+r.totals.active+' memberships across '+r.rows.length+' organizations.\\nCounts are memberships, not unique people.'+(r.fixtureOnly?'\\nScope: selected test members.':''):'';el('copy').disabled=!r;}
function status(message,error=false){el('status').textContent=message;el('status').className='status'+(error?' error':'');}
async function load(){try{const response=await fetch('/api/state');if(!response.ok)throw Error('Could not load the saved report.');render(await response.json());}catch(e){status(e.message,true);}}
el('run').onclick=async()=>{if(!state)return;el('run').disabled=true;el('refresh').disabled=true;status('Reading every organization. The saved report stays visible.');try{const response=await fetch('/api/report',{method:'POST',headers:{'Content-Type':'application/json','X-CSRF-Token':csrf},body:JSON.stringify({revision:state.revision,asOf:el('date').value})});const result=await response.json();render(result);status(response.ok?'Complete report saved. Every configured organization was read.':result.error,!response.ok);}catch(e){status('The request did not return. Load the saved report to check the result.',true);}finally{el('run').disabled=false;el('refresh').disabled=false;}};
el('refresh').onclick=load;el('copy').onclick=async()=>{try{await navigator.clipboard.writeText(el('summary').value);el('copyStatus').textContent='Copied.';}catch{el('summary').focus();el('summary').select();el('copyStatus').textContent='Press Ctrl+C or Command+C to copy the selected summary.';}};load();
</script></html>`;
}
The complete dashboard runs in Node.js. The request above also has cURL and PHP equivalents; those tabs cover the HTTP read. For a report inside one organization, continue with the board membership dashboard.
Check your result
- Advance past an exit date
- That membership moves from active to ended.
- Revoke one test key
- The previous complete report and its date remain unchanged.
- Restart the server
- The saved report loads without reading campai.