{"record":{"id":"4c1cd738e7bccb90","repo":"koala73/worldmonitor","slug":"select-exactly-one-scorecard-bloc-preset-or-custom","errorCode":null,"errorMessage":"Select exactly one scorecard bloc preset or custom member list.","messagePattern":"Select exactly one scorecard bloc preset or custom member list\\.","errorType":"validation","errorClass":"Error","httpStatus":400,"severity":"error","filePath":"server/worldmonitor/scorecard/v1/_bloc-presets.ts","lineNumber":30,"sourceCode":"    id: 'EU27',\n    label: 'European Union',\n    members: ['AT', 'BE', 'BG', 'HR', 'CY', 'CZ', 'DK', 'EE', 'FI', 'FR', 'DE', 'GR', 'HU', 'IE', 'IT', 'LV', 'LT', 'LU', 'MT', 'NL', 'PL', 'PT', 'RO', 'SK', 'SI', 'ES', 'SE'],\n  },\n  BRICS: { id: 'BRICS', label: 'BRICS', members: ['BR', 'RU', 'IN', 'CN', 'ZA', 'SA', 'EG', 'AE', 'ET', 'ID', 'IR'] },\n  GCC: { id: 'GCC', label: 'Gulf Cooperation Council', members: ['AE', 'BH', 'KW', 'OM', 'QA', 'SA'] },\n  ASEAN: { id: 'ASEAN', label: 'ASEAN', members: ['BN', 'KH', 'ID', 'LA', 'MY', 'MM', 'PH', 'SG', 'TH', 'TL', 'VN'] },\n  NATO: {\n    id: 'NATO',\n    label: 'NATO',\n    members: ['AL', 'BE', 'BG', 'CA', 'HR', 'CZ', 'DK', 'EE', 'FI', 'FR', 'DE', 'GR', 'HU', 'IS', 'IT', 'LV', 'LT', 'LU', 'ME', 'NL', 'MK', 'NO', 'PL', 'PT', 'RO', 'SK', 'SI', 'ES', 'SE', 'TR', 'GB', 'US'],\n  },\n};\n\nexport function resolveBlocSelection(input: { preset?: string; members?: string[] }): ScorecardBlocSelection {\n  const preset = String(input.preset || '').trim();\n  const members = Array.isArray(input.members) ? input.members : [];\n  if (Boolean(preset) === (members.length > 0)) {\n    throw new Error('Select exactly one scorecard bloc preset or custom member list.');\n  }\n  if (preset) {\n    if (!(preset in SCORECARD_BLOC_PRESETS)) throw new Error(`Unknown scorecard bloc preset: ${preset}`);\n    return SCORECARD_BLOC_PRESETS[preset as ScorecardBlocPreset];\n  }\n  if (members.length < 2 || members.length > 30) throw new Error('Custom scorecard blocs require 2-30 members.');\n  if (members.some((member) => !/^[A-Z]{2}$/.test(member))) {\n    throw new Error('Custom scorecard bloc members must be uppercase ISO-2 codes.');\n  }\n  if (new Set(members).size !== members.length) throw new Error('Custom scorecard bloc members must be unique.');\n  if (members.some((member) => !isInRankableUniverse(member))) {\n    throw new Error('Custom scorecard bloc members must belong to the public rankable country universe.');\n  }\n  const sorted = [...members].sort();\n  return { id: `custom:${sorted.join('-')}`, label: sorted.join(' + '), members: sorted };\n}\n","sourceCodeStart":12,"sourceCodeEnd":47,"githubUrl":"https://github.com/koala73/worldmonitor/blob/9361220cc013571781071f0206e4d80fd14b2f7f/server/worldmonitor/scorecard/v1/_bloc-presets.ts#L12-L47","documentation":"resolveBlocSelection in server/worldmonitor/scorecard/v1/_bloc-presets.ts enforces XOR selection: a caller must provide exactly one of a named bloc preset OR a custom member list. If both are provided (preset plus non-empty members), or neither is provided (no preset and empty members), it throws this error. Presets come from SCORECARD_BLOC_PRESETS (USMCA, EU27, ASEAN, BRICS, etc.); custom lists bypass the preset table entirely.","triggerScenarios":"Calling getBlocScorecardWithReader / resolveBlocSelection with: (a) { preset: 'EU27', members: ['DE','FR'] } — both supplied; (b) { } or { preset: '', members: [] } — nothing selected; (c) { preset: '  ' } (whitespace-only trims to empty) with no members; (d) { members: [] } only.","commonSituations":"1) A UI form keeps a previously selected preset in hidden state while the user also builds a custom list, sending both; 2) a script passes an empty object because its variables were unset; 3) whitespace-only preset strings that look set but trim to ''; 4) API clients send members: [] explicitly, which is treated as 'not selected' and combined with a preset — that is fine — but sending members plus preset is not.","solutions":["Send exactly one field: either { preset: '<name>' } or { members: [...] }, never both.","If both are set client-side, clear the preset when the user picks custom members (and vice versa) before submitting.","If no selection is intended, don't call the bloc scorecard at all rather than passing empty input.","Guard with a pre-check: Boolean(preset) !== (members.length > 0) must hold before calling."],"exampleFix":"// before\nresolveBlocSelection({ preset: 'EU27', members: ['DE', 'FR'] }); // throws\n// after\nconst input = members.length > 0 ? { members } : { preset };\nresolveBlocSelection(input);","handlingStrategy":"validation","validationCode":"function canSubmitBlocSelection(sel) {\n  const preset = String(sel.preset || '').trim();\n  const count = Array.isArray(sel.members) ? sel.members.length : 0;\n  return Boolean(preset) !== (count > 0); // exactly one of the two\n}","typeGuard":null,"tryCatchPattern":"try {\n  const bloc = await getBlocScorecard(selection);\n} catch (err) {\n  if (err.message.includes('Select exactly one scorecard bloc preset or custom member list')) {\n    resetSelectionUi(); return badRequest('choose either a preset or custom members, not both/neither');\n  }\n  throw err;\n}","preventionTips":["Clear preset when members are chosen and vice versa in the UI","Treat whitespace-only preset as empty before submitting","Never send members: [] alongside a preset","Assert XOR selection in a unit test on the request builder"],"tags":["validation","scorecard","bad-request","xor-input"],"backgroundTag":"invalid-bloc-selection","analyzedSha":"9361220cc013571781071f0206e4d80fd14b2f7f","analyzedAt":"2026-09-01T10:32:37.851Z","contentChangedAt":"2026-09-01T10:32:37.851Z","schemaVersion":2},"datasetVersion":"2026-09-14T05:17:10.506Z"}