Compare commits
2 commits
07152a467b
...
1705c88ba2
| Author | SHA1 | Date | |
|---|---|---|---|
| 1705c88ba2 | |||
| f58628e4a8 |
3 changed files with 452 additions and 0 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -30,6 +30,7 @@ scripts/*
|
||||||
# Tracked operational scripts (whitelisted; keep local dev helpers ignored above)
|
# Tracked operational scripts (whitelisted; keep local dev helpers ignored above)
|
||||||
!scripts/reclaimWebexHosts.js
|
!scripts/reclaimWebexHosts.js
|
||||||
!scripts/removeAdvancedMessaging.js
|
!scripts/removeAdvancedMessaging.js
|
||||||
|
!scripts/findEmptyLocations.js
|
||||||
!scripts/lib/
|
!scripts/lib/
|
||||||
!scripts/lib/**
|
!scripts/lib/**
|
||||||
characterize-*.js
|
characterize-*.js
|
||||||
|
|
@ -83,3 +84,4 @@ config/config.bak
|
||||||
# These contain emails, personIds, and per-user outcome — PII, never commit.
|
# These contain emails, personIds, and per-user outcome — PII, never commit.
|
||||||
reclaim-*.csv
|
reclaim-*.csv
|
||||||
remove-*.csv
|
remove-*.csv
|
||||||
|
empty-locations*.csv
|
||||||
|
|
|
||||||
407
scripts/findEmptyLocations.js
Normal file
407
scripts/findEmptyLocations.js
Normal file
|
|
@ -0,0 +1,407 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Find Webex Calling locations with no user-owned phone numbers —
|
||||||
|
* candidates for closed-store cleanup.
|
||||||
|
*
|
||||||
|
* Approach: two paginated pulls, one diff.
|
||||||
|
* 1. GET /v1/telephony/config/locations → every location
|
||||||
|
* 2. GET /v1/telephony/config/numbers → every provisioned
|
||||||
|
* number with owner + location info
|
||||||
|
* Bucket numbers by (location.id, owner.type). A location with zero
|
||||||
|
* PEOPLE-owned numbers is the "empty" verdict the user asked for.
|
||||||
|
*
|
||||||
|
* Each empty location is further categorized:
|
||||||
|
* - `safe-to-delete` → zero PEOPLE AND zero of everything else
|
||||||
|
* (no workspaces, no auto-attendants, no
|
||||||
|
* hunt groups, no call queues, no virtual
|
||||||
|
* lines, no paging groups, no unassigned
|
||||||
|
* numbers). The location shell is a ghost.
|
||||||
|
* - `needs-cleanup-first` → zero PEOPLE but other resources still
|
||||||
|
* exist. These are the "someone drew a
|
||||||
|
* line under the users but forgot the
|
||||||
|
* phones" ghosts. Break-fix through
|
||||||
|
* Control Hub before delete.
|
||||||
|
* - `has-users` → at least one PEOPLE-owned number, still
|
||||||
|
* in active use. Excluded from the report.
|
||||||
|
*
|
||||||
|
* Output:
|
||||||
|
* Console table summarising counts, plus a per-location inventory
|
||||||
|
* for the empty candidates (top N in console, full set in --report
|
||||||
|
* CSV).
|
||||||
|
*
|
||||||
|
* --execute mode (MUST be paired with --i-am-sure) deletes locations
|
||||||
|
* verdicted `safe-to-delete`, one at a time, via
|
||||||
|
* `DELETE /v1/telephony/config/locations/{id}`. This removes Webex
|
||||||
|
* Calling from the location; the underlying org-wide location record
|
||||||
|
* (billing/address) may still need to be removed in Control Hub
|
||||||
|
* depending on your subscription. Anything not `safe-to-delete` is
|
||||||
|
* never touched by --execute.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* node scripts/findEmptyLocations.js \
|
||||||
|
* [--include-has-users] # include has-users rows in CSV too
|
||||||
|
* [--report empty-locations.csv]
|
||||||
|
* [--limit N] # cap for --execute (e.g. pilot 5)
|
||||||
|
* [--offset N]
|
||||||
|
* [--execute --i-am-sure] # delete safe-to-delete locations
|
||||||
|
*
|
||||||
|
* Required Webex service-app scopes:
|
||||||
|
* spark-admin:telephony_config_read
|
||||||
|
* spark-admin:telephony_config_write (only if using --execute)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import 'dotenv/config';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { logger } from '../utils/logger.js';
|
||||||
|
import webex from '../integrations/webex/WebexClient.js';
|
||||||
|
import {
|
||||||
|
fetchAllPaginated,
|
||||||
|
runPool,
|
||||||
|
callWithRetry,
|
||||||
|
explainWebexError,
|
||||||
|
} from './lib/webexBulk.js';
|
||||||
|
|
||||||
|
// Owner types the Webex Calling numbers endpoint returns. Categorized
|
||||||
|
// here so a stray future owner type doesn't get silently dropped.
|
||||||
|
const OWNER_BUCKET = {
|
||||||
|
PEOPLE: 'people',
|
||||||
|
PLACE: 'workspaces',
|
||||||
|
AUTO_ATTENDANT: 'autoAttendants',
|
||||||
|
HUNT_GROUP: 'huntGroups',
|
||||||
|
CALL_QUEUE: 'callQueues',
|
||||||
|
VIRTUAL_LINE: 'virtualLines',
|
||||||
|
PAGING_GROUP: 'pagingGroups',
|
||||||
|
VOICEMAIL_GROUP: 'voicemailGroups',
|
||||||
|
GROUP_PAGING: 'pagingGroups',
|
||||||
|
RECEPTIONIST_CLIENT: 'receptionist',
|
||||||
|
};
|
||||||
|
|
||||||
|
function emptyBucket() {
|
||||||
|
return {
|
||||||
|
people: 0,
|
||||||
|
workspaces: 0,
|
||||||
|
autoAttendants: 0,
|
||||||
|
huntGroups: 0,
|
||||||
|
callQueues: 0,
|
||||||
|
virtualLines: 0,
|
||||||
|
pagingGroups: 0,
|
||||||
|
voicemailGroups: 0,
|
||||||
|
receptionist: 0,
|
||||||
|
unassigned: 0,
|
||||||
|
other: 0,
|
||||||
|
totalNumbers: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function bucketTotals(b) {
|
||||||
|
return b.people + b.workspaces + b.autoAttendants + b.huntGroups
|
||||||
|
+ b.callQueues + b.virtualLines + b.pagingGroups + b.voicemailGroups
|
||||||
|
+ b.receptionist + b.unassigned + b.other;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// CLI parsing
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function parseArgs(argv) {
|
||||||
|
const out = {
|
||||||
|
report: null,
|
||||||
|
execute: false,
|
||||||
|
iAmSure: false,
|
||||||
|
includeHasUsers: false,
|
||||||
|
limit: null,
|
||||||
|
offset: 0,
|
||||||
|
help: false,
|
||||||
|
};
|
||||||
|
for (let i = 0; i < argv.length; i++) {
|
||||||
|
const a = argv[i];
|
||||||
|
const next = () => argv[++i];
|
||||||
|
switch (a) {
|
||||||
|
case '--report': out.report = next(); break;
|
||||||
|
case '--execute': out.execute = true; break;
|
||||||
|
case '--i-am-sure': out.iAmSure = true; break;
|
||||||
|
case '--include-has-users': out.includeHasUsers = true; break;
|
||||||
|
case '--limit': out.limit = Number(next()); break;
|
||||||
|
case '--offset': out.offset = Number(next()); break;
|
||||||
|
case '-h': case '--help': out.help = true; break;
|
||||||
|
default:
|
||||||
|
if (a.startsWith('--')) {
|
||||||
|
console.error(`Unknown flag: ${a}`);
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function printHelp() {
|
||||||
|
const src = fs.readFileSync(new URL(import.meta.url), 'utf8');
|
||||||
|
const m = src.match(/\/\*\*([\s\S]*?)\*\//);
|
||||||
|
if (m) console.log(m[1].replace(/^\s*\*\s?/gm, ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Main
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const args = parseArgs(process.argv.slice(2));
|
||||||
|
if (args.help) { printHelp(); process.exit(0); }
|
||||||
|
|
||||||
|
console.log('📥 Fetching all Webex Calling locations…');
|
||||||
|
let locations;
|
||||||
|
try {
|
||||||
|
locations = await fetchAllPaginated('telephony/config/locations', {
|
||||||
|
arrayKey: 'locations',
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`❌ Failed to list locations: ${explainWebexError(err)}`);
|
||||||
|
console.error(
|
||||||
|
` The service app needs \`spark-admin:telephony_config_read\` scope,\n` +
|
||||||
|
` authorized as a Full / User admin on the org.`,
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
console.log(` → ${locations.length} locations`);
|
||||||
|
|
||||||
|
console.log('📥 Fetching all provisioned phone numbers…');
|
||||||
|
let numbers;
|
||||||
|
try {
|
||||||
|
numbers = await fetchAllPaginated('telephony/config/numbers', {
|
||||||
|
arrayKey: 'phoneNumbers',
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`❌ Failed to list numbers: ${explainWebexError(err)}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
console.log(` → ${numbers.length} numbers`);
|
||||||
|
|
||||||
|
// Bucket by location + owner type. Numbers without an owner are
|
||||||
|
// counted as `unassigned` — a location with only unassigned numbers
|
||||||
|
// is still empty from a "people using it" standpoint, but the
|
||||||
|
// presence of provisioned DIDs means cleanup work remains before
|
||||||
|
// delete.
|
||||||
|
const byLocation = new Map();
|
||||||
|
for (const l of locations) byLocation.set(l.id, emptyBucket());
|
||||||
|
|
||||||
|
const unknownOwnerTypes = new Set();
|
||||||
|
for (const n of numbers) {
|
||||||
|
const locId = n.location?.id;
|
||||||
|
if (!locId) continue;
|
||||||
|
let b = byLocation.get(locId);
|
||||||
|
if (!b) {
|
||||||
|
// Number references a location the locations endpoint didn't
|
||||||
|
// return. Odd but possible during org drift. Track it so the
|
||||||
|
// location shows up in the report.
|
||||||
|
b = emptyBucket();
|
||||||
|
byLocation.set(locId, b);
|
||||||
|
locations.push({
|
||||||
|
id: locId,
|
||||||
|
name: n.location?.name || '(unknown location)',
|
||||||
|
_synthetic: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
b.totalNumbers++;
|
||||||
|
const ownerType = n.owner?.type;
|
||||||
|
if (!ownerType) {
|
||||||
|
b.unassigned++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const key = OWNER_BUCKET[ownerType];
|
||||||
|
if (key) b[key]++;
|
||||||
|
else {
|
||||||
|
b.other++;
|
||||||
|
unknownOwnerTypes.add(ownerType);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (unknownOwnerTypes.size > 0) {
|
||||||
|
console.log(
|
||||||
|
` ℹ️ Saw owner types not in the known set (counted as \`other\`): ` +
|
||||||
|
`${[...unknownOwnerTypes].join(', ')}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verdict per location.
|
||||||
|
const verdicts = locations.map((l) => {
|
||||||
|
const b = byLocation.get(l.id) || emptyBucket();
|
||||||
|
const nonPeople = bucketTotals(b) - b.people;
|
||||||
|
let verdict;
|
||||||
|
if (b.people > 0) verdict = 'has-users';
|
||||||
|
else if (nonPeople === 0) verdict = 'safe-to-delete';
|
||||||
|
else verdict = 'needs-cleanup-first';
|
||||||
|
return { location: l, buckets: b, verdict };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Summary counts.
|
||||||
|
const summary = { hasUsers: 0, needsCleanup: 0, safeToDelete: 0 };
|
||||||
|
for (const v of verdicts) {
|
||||||
|
if (v.verdict === 'has-users') summary.hasUsers++;
|
||||||
|
else if (v.verdict === 'safe-to-delete') summary.safeToDelete++;
|
||||||
|
else summary.needsCleanup++;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\n📊 ${locations.length} total locations:`);
|
||||||
|
console.log(` ${summary.hasUsers.toString().padStart(4)} have users (excluded from report)`);
|
||||||
|
console.log(` ${summary.needsCleanup.toString().padStart(4)} zero users but other resources remain (workspace/AA/HG/etc)`);
|
||||||
|
console.log(` ${summary.safeToDelete.toString().padStart(4)} truly empty (safe to delete)`);
|
||||||
|
|
||||||
|
const empty = verdicts.filter((v) => v.verdict !== 'has-users');
|
||||||
|
|
||||||
|
if (empty.length > 0) {
|
||||||
|
console.log(`\n📋 Empty / needs-cleanup locations (showing up to 20):`);
|
||||||
|
console.log(
|
||||||
|
' ' +
|
||||||
|
'verdict people work aa hg cq vl pg other unassigned name',
|
||||||
|
);
|
||||||
|
for (const v of empty.slice(0, 20)) {
|
||||||
|
const b = v.buckets;
|
||||||
|
const row = [
|
||||||
|
v.verdict.padEnd(18),
|
||||||
|
String(b.people).padStart(6),
|
||||||
|
String(b.workspaces).padStart(5),
|
||||||
|
String(b.autoAttendants).padStart(3),
|
||||||
|
String(b.huntGroups).padStart(3),
|
||||||
|
String(b.callQueues).padStart(3),
|
||||||
|
String(b.virtualLines).padStart(3),
|
||||||
|
String(b.pagingGroups).padStart(3),
|
||||||
|
String(b.other).padStart(6),
|
||||||
|
String(b.unassigned).padStart(11),
|
||||||
|
v.location.name || '(unnamed)',
|
||||||
|
].join(' ');
|
||||||
|
console.log(` ${row}`);
|
||||||
|
}
|
||||||
|
if (empty.length > 20) {
|
||||||
|
console.log(` … and ${empty.length - 20} more (see --report CSV)`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Report CSV.
|
||||||
|
if (args.report) {
|
||||||
|
const reportPath = path.resolve(args.report);
|
||||||
|
const cols = [
|
||||||
|
'verdict', 'location_id', 'name',
|
||||||
|
'people', 'workspaces', 'auto_attendants', 'hunt_groups',
|
||||||
|
'call_queues', 'virtual_lines', 'paging_groups', 'voicemail_groups',
|
||||||
|
'receptionist', 'unassigned', 'other', 'total_numbers',
|
||||||
|
'address_city', 'address_state', 'address_country', 'timezone',
|
||||||
|
'time_zone_offset',
|
||||||
|
];
|
||||||
|
const escape = (v) => `"${String(v ?? '').replace(/"/g, '""')}"`;
|
||||||
|
const lines = [cols.join(',')];
|
||||||
|
const included = args.includeHasUsers ? verdicts : empty;
|
||||||
|
for (const v of included) {
|
||||||
|
const b = v.buckets;
|
||||||
|
const l = v.location;
|
||||||
|
const addr = l.address || {};
|
||||||
|
lines.push(cols.map((c) => {
|
||||||
|
switch (c) {
|
||||||
|
case 'verdict': return escape(v.verdict);
|
||||||
|
case 'location_id': return escape(l.id);
|
||||||
|
case 'name': return escape(l.name);
|
||||||
|
case 'people': return b.people;
|
||||||
|
case 'workspaces': return b.workspaces;
|
||||||
|
case 'auto_attendants': return b.autoAttendants;
|
||||||
|
case 'hunt_groups': return b.huntGroups;
|
||||||
|
case 'call_queues': return b.callQueues;
|
||||||
|
case 'virtual_lines': return b.virtualLines;
|
||||||
|
case 'paging_groups': return b.pagingGroups;
|
||||||
|
case 'voicemail_groups': return b.voicemailGroups;
|
||||||
|
case 'receptionist': return b.receptionist;
|
||||||
|
case 'unassigned': return b.unassigned;
|
||||||
|
case 'other': return b.other;
|
||||||
|
case 'total_numbers': return b.totalNumbers;
|
||||||
|
case 'address_city': return escape(addr.city);
|
||||||
|
case 'address_state': return escape(addr.state);
|
||||||
|
case 'address_country': return escape(l.address?.country || l.countryCode);
|
||||||
|
case 'timezone': return escape(l.timeZone);
|
||||||
|
case 'time_zone_offset': return escape(l.timezoneOffset);
|
||||||
|
default: return '';
|
||||||
|
}
|
||||||
|
}).join(','));
|
||||||
|
}
|
||||||
|
fs.writeFileSync(reportPath, lines.join('\n') + '\n', 'utf8');
|
||||||
|
console.log(`\n📝 Report written to ${reportPath} (${lines.length - 1} row${lines.length === 2 ? '' : 's'})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete path (guarded).
|
||||||
|
if (!args.execute) {
|
||||||
|
if (summary.safeToDelete > 0) {
|
||||||
|
console.log(
|
||||||
|
`\n🚦 DRY-RUN. To delete the ${summary.safeToDelete} safe-to-delete\n` +
|
||||||
|
` location(s), re-run with: --execute --i-am-sure\n` +
|
||||||
|
` Only locations with zero of everything will be deleted.\n` +
|
||||||
|
` Use --limit N to pilot on a subset.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!args.iAmSure) {
|
||||||
|
console.error(
|
||||||
|
`\n❌ --execute requires --i-am-sure. Deleting Webex Calling from a\n` +
|
||||||
|
` location is irreversible via API — you'd rebuild the location\n` +
|
||||||
|
` from scratch in Control Hub if this is a mistake.\n\n` +
|
||||||
|
` Add \`--i-am-sure\` (and consider \`--limit N\` for a pilot) to proceed.`,
|
||||||
|
);
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
const targets = verdicts
|
||||||
|
.filter((v) => v.verdict === 'safe-to-delete')
|
||||||
|
.slice(args.offset, args.limit ? args.offset + args.limit : undefined);
|
||||||
|
|
||||||
|
if (targets.length === 0) {
|
||||||
|
console.log(`\n✅ Nothing to delete (0 safe-to-delete locations after --offset/--limit).`);
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`\n🚀 DELETING ${targets.length} location(s), serial (concurrency=1). Ctrl-C to abort.\n`,
|
||||||
|
);
|
||||||
|
logger(
|
||||||
|
'webex:emptyloc:audit',
|
||||||
|
`START delete: count=${targets.length}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Serial (concurrency=1) — destructive ops shouldn't parallelize.
|
||||||
|
// Each 429/503 gets a Retry-After-aware retry via callWithRetry.
|
||||||
|
let ok = 0;
|
||||||
|
let failed = 0;
|
||||||
|
const outcomes = await runPool(targets, 1, async (v) => {
|
||||||
|
const l = v.location;
|
||||||
|
console.log(` • DELETE ${l.name} (${l.id}) …`);
|
||||||
|
await callWithRetry(() =>
|
||||||
|
webex.request('DELETE', `telephony/config/locations/${l.id}`),
|
||||||
|
);
|
||||||
|
console.log(` ✅ removed`);
|
||||||
|
return { deleted: true };
|
||||||
|
});
|
||||||
|
|
||||||
|
for (let i = 0; i < outcomes.length; i++) {
|
||||||
|
const t = targets[i];
|
||||||
|
const r = outcomes[i];
|
||||||
|
if (r.ok) {
|
||||||
|
ok++;
|
||||||
|
logger('webex:emptyloc:audit', `OK delete ${t.location.id} name="${t.location.name}"`);
|
||||||
|
} else {
|
||||||
|
failed++;
|
||||||
|
const msg = explainWebexError(r.error);
|
||||||
|
console.log(` ❌ ${t.location.name}: ${msg}`);
|
||||||
|
logger(
|
||||||
|
'webex:emptyloc:audit',
|
||||||
|
`FAIL delete ${t.location.id} name="${t.location.name}": ${msg}`,
|
||||||
|
'error',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\n✅ Done. ${ok} deleted, ${failed} failed.`);
|
||||||
|
logger('webex:emptyloc:audit', `END delete: ok=${ok} failed=${failed}`);
|
||||||
|
process.exit(failed === 0 ? 0 : 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error(`\n💥 Unhandled: ${err?.stack || err}`);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|
@ -157,6 +157,49 @@ export function seatsFree(l) {
|
||||||
return Math.max(0, total - used);
|
return Math.max(0, total - used);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Generic paginated fetch that follows the Webex `Link: <…>; rel="next"`
|
||||||
|
// header cursor. Accumulates every item in the response's `arrayKey` field
|
||||||
|
// (defaults to 'items' — the shape most Webex list endpoints use). Some
|
||||||
|
// endpoints use a different key (e.g. /telephony/config/numbers returns
|
||||||
|
// `phoneNumbers`); pass `arrayKey` in that case.
|
||||||
|
//
|
||||||
|
// Uses WebexClient.requestRaw() directly so we can read headers. First
|
||||||
|
// call is relative (`endpoint`); subsequent calls follow the absolute
|
||||||
|
// URLs from the Link header, which carry the cursor query string.
|
||||||
|
export async function fetchAllPaginated(endpoint, {
|
||||||
|
params = null,
|
||||||
|
arrayKey = 'items',
|
||||||
|
pageSize = 1000,
|
||||||
|
} = {}) {
|
||||||
|
const firstParams = { max: pageSize, ...(params || {}) };
|
||||||
|
let { data, headers } = await webex.requestRaw('GET', endpoint, null, firstParams);
|
||||||
|
const all = [];
|
||||||
|
const pickArray = (d) => {
|
||||||
|
const arr = d?.[arrayKey];
|
||||||
|
return Array.isArray(arr) ? arr : [];
|
||||||
|
};
|
||||||
|
all.push(...pickArray(data));
|
||||||
|
|
||||||
|
let nextUrl = parseLinkNext(headers?.link || headers?.Link);
|
||||||
|
while (nextUrl) {
|
||||||
|
({ data, headers } = await webex.requestRaw('GET', nextUrl));
|
||||||
|
all.push(...pickArray(data));
|
||||||
|
nextUrl = parseLinkNext(headers?.link || headers?.Link);
|
||||||
|
}
|
||||||
|
return all;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseLinkNext(linkHeader) {
|
||||||
|
if (!linkHeader || typeof linkHeader !== 'string') return null;
|
||||||
|
// Tolerate multiple entries (comma-separated); grab the first `rel="next"`.
|
||||||
|
const entries = linkHeader.split(/,\s*(?=<)/);
|
||||||
|
for (const e of entries) {
|
||||||
|
const m = e.match(/^\s*<([^>]+)>\s*;\s*rel\s*=\s*"?next"?/i);
|
||||||
|
if (m) return m[1];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
export function explainWebexError(err) {
|
export function explainWebexError(err) {
|
||||||
const status = err?.response?.status;
|
const status = err?.response?.status;
|
||||||
const apiMsg =
|
const apiMsg =
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue