@bongos/core 1.19.805 → 1.19.806

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -883,7 +883,7 @@
883
883
  // this client gate deliberately doesn't re-derive the protected-module list
884
884
  // (that roster lives server-side only, module-scope-map.js) — a non-Archon
885
885
  // owner/manager sees the control on a protected-scope goal and gets a clear
886
- // toast if the server 403s (see inviteMember's error handling below).
886
+ // toast if the server 403s (see sendInvitation's error handling below).
887
887
  const isOwner = sameId(g.created_by, selfId());
888
888
  const canManage = g.status === 'open' && (isArchon() || isOwner || iAmLead);
889
889
  renderActions(g, { joined, canManage });
@@ -1122,6 +1122,49 @@
1122
1122
  }
1123
1123
  return state.directory;
1124
1124
  }
1125
+ // WHO IS STILL INVITABLE. Two exclusions, both of which exist to stop the page
1126
+ // offering a name that can only fail:
1127
+ // - members, which POST /invitations 409s as `already_a_member`;
1128
+ // - anyone holding a PENDING row, which it 409s as `invite_pending`.
1129
+ // The second is the one that matters, because a pending invitation is INVISIBLE
1130
+ // from the member list — it is not a membership — so without this the user picks
1131
+ // a perfectly ordinary-looking name and is handed an error they had no way to
1132
+ // predict. `state.pendingRequests` carries BOTH kinds (invites the goal sent and
1133
+ // join-requests it received) and the server refuses on either, so neither is
1134
+ // filtered out here.
1135
+ function invitableBuilders(members) {
1136
+ const excluded = new Set((members || []).map((m) => String(m.builder_id)));
1137
+ for (const r of state.pendingRequests || []) excluded.add(String(r.builder_id));
1138
+ return (state.directory || []).filter((b) => !excluded.has(String(b.id)));
1139
+ }
1140
+ function builderOptionsHtml(list, selectedId) {
1141
+ return list.map((b) => {
1142
+ const id = String(b.id);
1143
+ const sel = selectedId && id === String(selectedId) ? ' selected' : '';
1144
+ return `<option value="${escapeHtml(id)}"${sel}>${escapeHtml(b.display_name || ('@' + b.github_login))}</option>`;
1145
+ }).join('');
1146
+ }
1147
+ // THE INVITE BOX SENDS AN INVITATION; IT DOES NOT ADD ANYONE (task 1002886).
1148
+ //
1149
+ // This control used to POST /goals/:id/members — the immediate force-add — and
1150
+ // then toast 'Invited.', which was simply untrue: the person was already in the
1151
+ // goal and had never been asked. Meanwhile the consented flow ADR 0112 is built
1152
+ // around (POST /goals/:id/invitations -> a pending row -> the invitee accepts)
1153
+ // had its receiving half fully wired in goal-inbox.js and NO sender anywhere in
1154
+ // the hall, so nothing in the product ever created an invitation.
1155
+ //
1156
+ // Now: the primary control invites, and the force-add survives as its own
1157
+ // explicitly-labelled affordance (owner ruling, 2026-09-16) so nobody loses a
1158
+ // capability they had — seeding a goal with a team who already agreed offline is
1159
+ // a real thing people do. The rule the owner set is that BOTH paths say what
1160
+ // they actually do; relocating the lie would have been worse than leaving it.
1161
+ //
1162
+ // NO MEMBERSHIP-KIND PICKER ON THE INVITE HALF, and that is not an oversight:
1163
+ // `goal_membership_requests` has no membership_kind column, so an accepted
1164
+ // invitation lands through addGoalMember's default 'member'. A picker here would
1165
+ // be a choice silently dropped on the floor — the same class of defect as the
1166
+ // toast it replaces. The form says so instead, and the picker stays on the
1167
+ // add-directly path, where it IS honoured.
1125
1168
  function renderInviteBox(goalId, canInvite, members) {
1126
1169
  const box = $('#goal-invite');
1127
1170
  if (!box) return;
@@ -1129,6 +1172,7 @@
1129
1172
  box.hidden = true;
1130
1173
  box.innerHTML = '';
1131
1174
  state.inviteOpen = false;
1175
+ state.addDirectOpen = false;
1132
1176
  return;
1133
1177
  }
1134
1178
  box.hidden = false;
@@ -1141,42 +1185,111 @@
1141
1185
  });
1142
1186
  return;
1143
1187
  }
1144
- const memberIds = new Set((members || []).map((m) => String(m.builder_id)));
1145
- const invitable = (state.directory || []).filter((b) => !memberIds.has(String(b.id)));
1146
- const options = invitable
1147
- .map((b) => `<option value="${escapeHtml(String(b.id))}">${escapeHtml(b.display_name || ('@' + b.github_login))}</option>`)
1148
- .join('');
1188
+ // This function rebuilds the box from scratch on every call, and it is called
1189
+ // again when the pending-request list lands (see loadGoalMembership). Carry the
1190
+ // half-filled form across that redraw, or a note someone is part-way through
1191
+ // typing vanishes under them.
1192
+ const keep = {
1193
+ builder: $('#goal-invite-builder')?.value || '',
1194
+ note: $('#goal-invite-note')?.value || '',
1195
+ direct: $('#goal-direct-builder')?.value || '',
1196
+ kind: $('#goal-direct-kind')?.value || 'member',
1197
+ };
1198
+ const invitable = invitableBuilders(members);
1199
+ const none = !invitable.length;
1200
+ const emptyLabel = 'Nobody left to invite';
1149
1201
  box.innerHTML = `
1150
1202
  <form class="goal-invite__form" id="goal-invite-form">
1151
- <select id="goal-invite-builder" required ${invitable.length ? '' : 'disabled'}>
1152
- ${invitable.length ? `<option value="" disabled selected>Choose a builder…</option>${options}`
1153
- : '<option value="">Everyone is already a member</option>'}
1154
- </select>
1155
- <select id="goal-invite-kind">
1156
- <option value="member">Member</option>
1157
- <option value="lead">Manager</option>
1203
+ <select id="goal-invite-builder" required ${none ? 'disabled' : ''}>
1204
+ ${none ? `<option value="">${emptyLabel}</option>`
1205
+ : `<option value="" disabled${keep.builder ? '' : ' selected'}>Choose a builder…</option>${builderOptionsHtml(invitable, keep.builder)}`}
1158
1206
  </select>
1159
- <button class="landing__btn" type="submit" ${invitable.length ? '' : 'disabled'}>Invite</button>
1207
+ <input id="goal-invite-note" type="text" maxlength="500" placeholder="Note (optional) — why you're asking" value="${escapeHtml(keep.note)}" ${none ? 'disabled' : ''}>
1208
+ <button class="landing__btn" type="submit" ${none ? 'disabled' : ''}>Send invitation</button>
1160
1209
  <button class="btn-ghost" id="goal-invite-cancel" type="button">Cancel</button>
1161
- </form>`;
1210
+ </form>
1211
+ <p class="goal-invite__hint">They join as a member once they accept — you can change that afterwards. Nobody is added until they do.</p>
1212
+ ${state.addDirectOpen ? `
1213
+ <form class="goal-invite__form goal-invite__direct" id="goal-direct-form">
1214
+ <select id="goal-direct-builder" required ${none ? 'disabled' : ''}>
1215
+ ${none ? `<option value="">${emptyLabel}</option>`
1216
+ : `<option value="" disabled${keep.direct ? '' : ' selected'}>Choose a builder…</option>${builderOptionsHtml(invitable, keep.direct)}`}
1217
+ </select>
1218
+ <select id="goal-direct-kind" ${none ? 'disabled' : ''}>
1219
+ <option value="member"${keep.kind === 'member' ? ' selected' : ''}>Member</option>
1220
+ <option value="lead"${keep.kind === 'lead' ? ' selected' : ''}>Manager</option>
1221
+ </select>
1222
+ <button class="btn-ghost" type="submit" ${none ? 'disabled' : ''}>Add without asking</button>
1223
+ <button class="btn-ghost" id="goal-direct-cancel" type="button">Cancel</button>
1224
+ </form>
1225
+ <p class="goal-invite__hint">They are added straight away and never asked. Use this only for someone who has already agreed.</p>`
1226
+ : '<button class="goal-invite__more" id="goal-direct-open" type="button">Or add someone without asking…</button>'}`;
1162
1227
  $('#goal-invite-cancel')?.addEventListener('click', () => {
1163
1228
  state.inviteOpen = false;
1229
+ state.addDirectOpen = false;
1164
1230
  renderInviteBox(goalId, canInvite, members);
1165
1231
  });
1166
1232
  $('#goal-invite-form')?.addEventListener('submit', (ev) => {
1167
1233
  ev.preventDefault();
1168
1234
  const builderId = $('#goal-invite-builder')?.value;
1169
- const membershipKind = $('#goal-invite-kind')?.value || 'member';
1170
- if (builderId) inviteMember(goalId, builderId, membershipKind);
1235
+ const note = $('#goal-invite-note')?.value || '';
1236
+ if (builderId) sendInvitation(goalId, builderId, note);
1237
+ });
1238
+ $('#goal-direct-open')?.addEventListener('click', () => {
1239
+ state.addDirectOpen = true;
1240
+ renderInviteBox(goalId, canInvite, members);
1241
+ });
1242
+ $('#goal-direct-cancel')?.addEventListener('click', () => {
1243
+ state.addDirectOpen = false;
1244
+ renderInviteBox(goalId, canInvite, members);
1245
+ });
1246
+ $('#goal-direct-form')?.addEventListener('submit', (ev) => {
1247
+ ev.preventDefault();
1248
+ const builderId = $('#goal-direct-builder')?.value;
1249
+ const membershipKind = $('#goal-direct-kind')?.value || 'member';
1250
+ if (builderId) addMemberDirectly(goalId, builderId, membershipKind);
1171
1251
  });
1172
1252
  }
1173
- async function inviteMember(goalId, builderId, membershipKind) {
1253
+ // The ADR 0112 sender. 201 means a PENDING row exists — not a membership — so
1254
+ // the toast may not claim the person is in the goal.
1255
+ async function sendInvitation(goalId, builderId, note) {
1256
+ try {
1257
+ const body = { builder_id: Number(builderId) };
1258
+ const trimmed = String(note || '').trim();
1259
+ if (trimmed) body.note = trimmed;
1260
+ await postJSON(`${API}/goals/${encodeURIComponent(goalId)}/invitations`, body);
1261
+ toast('Invitation sent — they join once they accept.');
1262
+ state.inviteOpen = false;
1263
+ state.addDirectOpen = false;
1264
+ await loadDetail(goalId, { force: true });
1265
+ renderDetail(goalId);
1266
+ renderList();
1267
+ } catch (err) {
1268
+ const code = err?.data?.error;
1269
+ // `invite_pending` and `already_a_member` are both filtered out of the
1270
+ // dropdown above, so reaching either means the goal changed under this form
1271
+ // — say what happened rather than a generic failure, because the name really
1272
+ // was offered a moment ago.
1273
+ if (code === 'invite_pending') toast('They already have an invitation or a join-request pending on this goal.');
1274
+ else if (code === 'already_a_member') toast('They joined this goal already.');
1275
+ else if (code === 'builder_not_found') toast('That builder no longer exists.');
1276
+ else if (code === 'archon_required_for_protected_scope') toast('This goal is scoped to a protected module — only an Archon may bring someone in.');
1277
+ else if (code === 'metic_required_for_protected_scope') toast('A protected-scope goal requires a Metic+ member.');
1278
+ else if (code === 'not_owner_or_manager') toast('Only this goal\'s owner or a manager (or an Archon) may invite.');
1279
+ else if (code === 'goal_not_open') toast('This goal is not open.');
1280
+ else toast('Could not send that invitation just now.');
1281
+ }
1282
+ }
1283
+ // The force-add, kept on purpose and labelled for what it is. Same endpoint the
1284
+ // '+ Invite' button used to call behind the word 'Invited.'
1285
+ async function addMemberDirectly(goalId, builderId, membershipKind) {
1174
1286
  try {
1175
1287
  // `membership_kind`, not `role` — ADR 0174 reserves that word for the three
1176
1288
  // crafts (task 1003013).
1177
1289
  await postJSON(`${API}/goals/${encodeURIComponent(goalId)}/members`, { builder_id: Number(builderId), membership_kind: membershipKind });
1178
- toast('Invited.');
1290
+ toast('Added — they are in the goal now, without being asked.');
1179
1291
  state.inviteOpen = false;
1292
+ state.addDirectOpen = false;
1180
1293
  await loadDetail(goalId, { force: true });
1181
1294
  renderDetail(goalId);
1182
1295
  renderList();
@@ -1184,9 +1297,9 @@
1184
1297
  const code = err?.data?.error;
1185
1298
  if (code === 'archon_required_for_protected_scope') toast('This goal is scoped to a protected module — only an Archon may add members.');
1186
1299
  else if (code === 'metic_required_for_protected_scope') toast('A protected-scope goal requires a Metic+ member.');
1187
- else if (code === 'not_owner_or_manager') toast('Only this goal\'s owner or a manager (or an Archon) may invite.');
1300
+ else if (code === 'not_owner_or_manager') toast('Only this goal\'s owner or a manager (or an Archon) may add members.');
1188
1301
  else if (code === 'goal_not_open') toast('This goal is not open.');
1189
- else toast('Could not invite that builder just now.');
1302
+ else toast('Could not add that builder just now.');
1190
1303
  }
1191
1304
  }
1192
1305
  async function joinLeave(goalId, action) {
@@ -1324,7 +1437,16 @@
1324
1437
  if (!sameId(state.current, goalId)) return;
1325
1438
  renderPendingRequests();
1326
1439
  const d = currentDetail();
1327
- if (d) renderActions(d.goal, { joined: (d.members || []).some((m) => sameId(m.builder_id, selfId())), canManage });
1440
+ if (d) {
1441
+ renderActions(d.goal, { joined: (d.members || []).some((m) => sameId(m.builder_id, selfId())), canManage });
1442
+ // The invite dropdown's exclusion set reads state.pendingRequests, which
1443
+ // lands HERE — after renderDetail has already drawn the box. Without this
1444
+ // redraw a form opened before this resolved keeps offering a builder whose
1445
+ // invitation is already pending, which is the exact invisible 409 the
1446
+ // filter exists to prevent. renderInviteBox carries the half-filled form
1447
+ // across the redraw.
1448
+ if (state.inviteOpen) renderInviteBox(goalId, canManage, d.members || []);
1449
+ }
1328
1450
  }
1329
1451
  async function loadDetail(goalId, { force = false } = {}) {
1330
1452
  if (!force && state.details.has(String(goalId))) return state.details.get(String(goalId));
@@ -200,7 +200,22 @@
200
200
  collapsed behind "+ Invite" (disclosure, closed by default — task 2171). */
201
201
  .goal-invite { margin: 4px 0 10px; }
202
202
  .goal-invite__form { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
203
- .goal-invite__form select#goal-invite-builder { flex: 1; min-width: 180px; }
203
+ .goal-invite__form select#goal-invite-builder,
204
+ .goal-invite__form select#goal-direct-builder { flex: 1; min-width: 180px; }
205
+ .goal-invite__form input#goal-invite-note { flex: 1; min-width: 180px; }
206
+
207
+ /* task 1002886 — BOTH admission paths now say what they do, so both carry a
208
+ line of their own: the invite half promises nobody is added until they
209
+ accept, the force-add half admits nobody is asked. Muted, because this is
210
+ the consequence of the button beside it, not a warning. */
211
+ .goal-invite__hint { margin: 4px 0 8px; font-size: 12.5px; color: var(--ink-faint); max-width: var(--measure); }
212
+ /* The force-add is deliberately QUIETER than the invite it sits under — same
213
+ authority, but it is the path that skips consent, so it does not get equal
214
+ visual weight. It stays one click away, never hidden. */
215
+ .goal-invite__more { background: none; border: 0; padding: 0; margin: 0 0 8px; cursor: pointer;
216
+ font: inherit; font-size: 12.5px; color: var(--ink-soft); text-decoration: underline; }
217
+ .goal-invite__more:hover { color: var(--ink); }
218
+ .goal-invite__direct { padding-top: 8px; border-top: var(--line) solid var(--rule-soft); }
204
219
 
205
220
  /* task 1953 — private goals: the lock badge (meta line) + the goal's own
206
221
  pending invite/join-request queue (manager view, next to the invite box). */
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.805",
3
+ "version": "1.19.806",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.805",
9
+ "version": "1.19.806",
10
10
  "license": "AGPL-3.0-or-later",
11
11
  "dependencies": {
12
12
  "express": "^4.21.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.805",
3
+ "version": "1.19.806",
4
4
  "description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "main": "src/platform-server.js",
package/src/module-api.js CHANGED
@@ -71,7 +71,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
71
71
  // there. scripts/gds/bump-version.js still rewrites the literal below; it appends
72
72
  // the entry to that file. Look for a version's history there, not here.
73
73
  // ---------------------------------------------------------------------------
74
- const CORE_VERSION = '1.19.805'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.806'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
75
75
 
76
76
  // A namespaced logger so a module's log lines are attributable + consistent.
77
77
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -0,0 +1,307 @@
1
+ // tests/goal_invite_consent.mjs — the goals page INVITES; it does not force-add
2
+ // behind the word "Invited." (task 1002886, goal 1000095).
3
+ //
4
+ // WHY THIS FILE EXISTS. The '+ Invite' control posted to POST /goals/:id/members
5
+ // — the immediate force-add — and then toasted 'Invited.' The person was already
6
+ // in the goal and had never been asked. Meanwhile ADR 0112's consented flow
7
+ // (POST /goals/:id/invitations -> a pending row -> the invitee accepts) had its
8
+ // RECEIVING half fully wired in goal-inbox.js and no sender anywhere in the hall,
9
+ // so nothing in the product ever created an invitation.
10
+ //
11
+ // Nothing in tests/ touched the invite box before this file — which is how a
12
+ // control could claim one thing and do the opposite for as long as it did. The
13
+ // lesson generalises past this button: assert the REQUEST a control issues and
14
+ // the SENTENCE it then shows, because those two drifting apart is invisible to
15
+ // any test that only checks the control renders.
16
+ //
17
+ // It runs the REAL goals-render.js (plus the real goals-lib, task-lib and kit)
18
+ // in a vm against a stub DOM and a recording postJSON, and drives renderDetail —
19
+ // the actual entry point, not the private renderer — so what is asserted is the
20
+ // call the page really makes.
21
+ //
22
+ // Run: node tests/goal_invite_consent.mjs
23
+ import { strict as assert } from 'node:assert';
24
+ import fs from 'node:fs';
25
+ import path from 'node:path';
26
+ import vm from 'node:vm';
27
+ import { fileURLToPath } from 'node:url';
28
+ import { makeRunner } from './helpers.mjs';
29
+
30
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
31
+ const read = (rel) => fs.readFileSync(path.join(ROOT, rel), 'utf8');
32
+ const P = 'modules/hall-ui/public/';
33
+ const RENDER_JS = read(`${P}goals-render.js`);
34
+
35
+ const { test, summary } = makeRunner();
36
+ const tick = () => new Promise((r) => setTimeout(r, 0));
37
+
38
+ const SELF = 7001; // the viewer: the goal's creator, so canManage is true
39
+ const MEMBER_ID = 7002; // already in the goal
40
+ const PENDING_ID = 7003; // holds a pending invitation the goal sent
41
+ const FREE_ID = 7004; // neither — the only genuinely invitable builder
42
+
43
+ function makeEl(id) {
44
+ const el = {
45
+ id,
46
+ innerHTML: '', textContent: '', value: '', className: '',
47
+ hidden: false, disabled: false, checked: false,
48
+ options: [], style: {}, dataset: {},
49
+ children: [],
50
+ classList: { add() {}, remove() {}, toggle() {}, contains: () => false },
51
+ appendChild(c) { el.children.push(c); return c; },
52
+ setAttribute() {}, removeAttribute() {}, focus() {}, scrollIntoView() {},
53
+ _on: new Map(),
54
+ addEventListener(ev, fn) {
55
+ if (!el._on.has(ev)) el._on.set(ev, []);
56
+ el._on.get(ev).push(fn);
57
+ },
58
+ fire(ev, arg) {
59
+ for (const fn of el._on.get(ev) || []) fn(arg || { preventDefault() {} });
60
+ },
61
+ querySelector() { return null; },
62
+ querySelectorAll() { return []; },
63
+ };
64
+ return el;
65
+ }
66
+
67
+ function harness({ pendingRequests = [] } = {}) {
68
+ const els = new Map();
69
+ // The box is re-rendered by replacing innerHTML, and the stub DOM does not
70
+ // parse HTML — so the controls the page writes into #goal-invite are modelled
71
+ // by their ids, which is exactly how the page reaches them ($('#id')).
72
+ // Ids the invite box writes INTO itself. Listed rather than inferred because
73
+ // the stub does not parse HTML — this is the seam where it has to imitate the
74
+ // one thing real innerHTML does that matters here: destroy what was there.
75
+ const INVITE_CHILD_IDS = [
76
+ 'goal-invite-open', 'goal-invite-form', 'goal-invite-builder', 'goal-invite-note',
77
+ 'goal-invite-cancel', 'goal-direct-open', 'goal-direct-form', 'goal-direct-builder',
78
+ 'goal-direct-kind', 'goal-direct-cancel',
79
+ ];
80
+ const getEl = (id) => {
81
+ const key = String(id).replace(/^#/, '');
82
+ if (!els.has(key)) {
83
+ const el = makeEl(key);
84
+ if (key === 'goal-invite') {
85
+ // THE BUG THIS MODELS IS REAL: renderInviteBox rebuilds the box on every
86
+ // call, and a stub that kept the old nodes alive would keep their click
87
+ // handlers too — so a single click would re-enter the renderer once per
88
+ // previous render. That is a defect in the STUB, not the page, but only
89
+ // because the page really does replace these nodes. Keep them in step.
90
+ let html = '';
91
+ Object.defineProperty(el, 'innerHTML', {
92
+ get: () => html,
93
+ set: (v) => { html = String(v); for (const cid of INVITE_CHILD_IDS) els.delete(cid); },
94
+ });
95
+ }
96
+ els.set(key, el);
97
+ }
98
+ return els.get(key);
99
+ };
100
+ const documentObj = {
101
+ documentElement: { dataset: {}, style: {}, classList: { add() {}, remove() {} } },
102
+ body: makeEl('body'),
103
+ getElementById: getEl,
104
+ querySelector: (sel) => getEl(sel),
105
+ querySelectorAll: () => [],
106
+ createElement: () => makeEl(),
107
+ addEventListener() {},
108
+ };
109
+
110
+ const posts = [];
111
+ const toasts = [];
112
+ const state = {
113
+ current: 1,
114
+ view: 'detail',
115
+ details: new Map(),
116
+ directory: [
117
+ { id: MEMBER_ID, display_name: 'Ada Member', github_login: 'ada' },
118
+ { id: PENDING_ID, display_name: 'Ben Pending', github_login: 'ben' },
119
+ { id: FREE_ID, display_name: 'Cleo Free', github_login: 'cleo' },
120
+ ],
121
+ pendingRequests,
122
+ myPendingRequest: null,
123
+ inviteOpen: false,
124
+ addDirectOpen: false,
125
+ // The rest of the page state renderDetail walks through on its way to the
126
+ // invite box. Shapes copied from goals-page.js's initial state — the
127
+ // renderers read several of these unguarded, so a partial stub throws
128
+ // before execution ever reaches what is under test here.
129
+ workSort: { todo: { by: null, dir: 'desc' }, done: { by: null, dir: 'desc' } },
130
+ work: { todo: [], done: [] },
131
+ workLoaded: false,
132
+ claimsByTask: new Map(),
133
+ conflictPairs: null,
134
+ conflictSource: null,
135
+ rollups: new Map(),
136
+ activity: new Map(),
137
+ };
138
+
139
+ const windowObj = {
140
+ location: { assign() {}, href: '/goals' },
141
+ localStorage: { getItem: () => null, setItem() {}, removeItem() {} },
142
+ scrollTo() {}, addEventListener() {},
143
+ matchMedia: () => ({ matches: false, addEventListener() {}, addListener() {} }),
144
+ OTB: {
145
+ escapeHtml: (s) => String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;'),
146
+ $: (sel) => documentObj.querySelector(sel),
147
+ fmtNum: (n) => String(n ?? ''),
148
+ fmtInt: (n) => String(n ?? ''),
149
+ fmtDate: (d) => String(d ?? '').slice(0, 10),
150
+ toast: (m) => toasts.push(String(m)),
151
+ rankLabel: (r) => String(r ?? ''),
152
+ categoryChip: () => '',
153
+ num: (n) => Number(n) || 0,
154
+ },
155
+ };
156
+
157
+ const sandbox = {
158
+ window: windowObj, document: documentObj, location: windowObj.location,
159
+ console: { error() {}, warn() {}, log() {} },
160
+ setTimeout, clearTimeout, URLSearchParams, encodeURIComponent, Date, Math, JSON, Set, Map,
161
+ fetch: () => Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve({}) }),
162
+ };
163
+ sandbox.globalThis = sandbox;
164
+ vm.createContext(sandbox);
165
+ for (const f of ['hall-kit.js', 'task-lib.js', 'goals-lib.js', 'goals-render.js']) {
166
+ vm.runInContext(read(P + f), sandbox, { filename: f });
167
+ }
168
+
169
+ const goal = {
170
+ id: 1, version_id: 'BONGOS-V2', status: 'open', visibility: 'public',
171
+ created_by: SELF, title: 'A goal', scope_modules: [], accepting_requests: true,
172
+ };
173
+ const members = [{ builder_id: MEMBER_ID, display_name: 'Ada Member', membership_kind: 'member' }];
174
+ const detail = { goal, members, criteria: [], satisfied: [], tasks: [] };
175
+ state.details.set('1', detail);
176
+
177
+ const api = sandbox.window.OTBGoalsRender({
178
+ API: '/api/bongos',
179
+ curLabel: () => 'Credits',
180
+ // URL-aware, because the renderers walk several feeds on the way to the
181
+ // invite box and a one-shape-fits-all reply feeds the wrong array into the
182
+ // wrong pager.
183
+ getJSON: async (url) => {
184
+ if (/\/goals\/\d+\/requests$/.test(url)) return { requests: pendingRequests };
185
+ if (/\/goals\/inbox$/.test(url)) return { myPendingRequests: [] };
186
+ if (/\/builders\/directory/.test(url)) return { builders: state.directory };
187
+ if (/\/tasks/.test(url)) return { tasks: [] };
188
+ if (/\/claims/.test(url)) return { claims: [] };
189
+ return {};
190
+ },
191
+ patchJSON: async () => ({}),
192
+ postJSON: async (url, body) => { posts.push({ url, body }); return { ok: true }; },
193
+ sameId: (a, b) => String(a ?? '') === String(b ?? ''),
194
+ selfId: () => SELF,
195
+ state,
196
+ get workTabs() { return { activate() {} }; },
197
+ });
198
+
199
+ return { api, state, posts, toasts, getEl, els };
200
+ }
201
+
202
+ // Open the invite box the way a user does: render the detail, then click
203
+ // "+ Invite". Returns the harness with the form on screen.
204
+ async function openInviteForm(h) {
205
+ h.api.renderDetail(1);
206
+ await tick();
207
+ h.getEl('goal-invite-open').fire('click');
208
+ await tick(); await tick();
209
+ return h;
210
+ }
211
+
212
+ // ---------------------------------------------------------------------------
213
+
214
+ await test('the source no longer names the force-add as an "invite"', () => {
215
+ assert.ok(!/async function inviteMember\b/.test(RENDER_JS),
216
+ 'inviteMember() was the force-add wearing the word invite — it is split in two');
217
+ assert.match(RENDER_JS, /async function sendInvitation\(/, 'the consented sender exists');
218
+ assert.match(RENDER_JS, /async function addMemberDirectly\(/, 'the force-add is named for what it does');
219
+ assert.ok(!/toast\('Invited\.'\)/.test(RENDER_JS), "the 'Invited.' toast on a force-add is gone");
220
+ });
221
+
222
+ await test('the invite form posts to /invitations and never claims they joined', async () => {
223
+ const h = await openInviteForm(harness());
224
+ h.getEl('goal-invite-builder').value = String(FREE_ID);
225
+ h.getEl('goal-invite-note').value = ' we could use your eye on this ';
226
+ h.getEl('goal-invite-form').fire('submit');
227
+ await tick(); await tick();
228
+
229
+ assert.equal(h.posts.length, 1, 'exactly one write');
230
+ const { url, body } = h.posts[0];
231
+ assert.match(url, /\/goals\/1\/invitations$/, 'the CONSENTED endpoint, not /members');
232
+ assert.ok(!/\/members$/.test(url), 'the force-add endpoint is not what Invite calls');
233
+ assert.equal(body.builder_id, FREE_ID);
234
+ assert.equal(body.note, 'we could use your eye on this', 'the note is sent, trimmed');
235
+ assert.ok(!('membership_kind' in body),
236
+ 'the invitations route carries no membership_kind — sending one would be a choice dropped in silence');
237
+
238
+ const said = h.toasts.join(' ');
239
+ assert.match(said, /accept/i, 'the toast turns on their acceptance');
240
+ assert.ok(!/^Invited\.$/.test(h.toasts[0]), 'the old lie is not relocated');
241
+ assert.ok(!/\b(added|joined|is now a member)\b/i.test(said),
242
+ `nobody joined anything yet — got: ${JSON.stringify(h.toasts)}`);
243
+ });
244
+
245
+ await test('an empty note is omitted rather than sent as an empty string', async () => {
246
+ const h = await openInviteForm(harness());
247
+ h.getEl('goal-invite-builder').value = String(FREE_ID);
248
+ h.getEl('goal-invite-note').value = ' ';
249
+ h.getEl('goal-invite-form').fire('submit');
250
+ await tick(); await tick();
251
+ assert.ok(!('note' in h.posts[0].body), 'a blank note is absent, not ""');
252
+ });
253
+
254
+ await test('the invite form offers no membership-kind picker', async () => {
255
+ const h = await openInviteForm(harness());
256
+ const html = h.getEl('goal-invite').innerHTML;
257
+ assert.ok(!/id="goal-invite-kind"/.test(html),
258
+ 'the picker cannot be honoured by an invitation — goal_membership_requests has no such column');
259
+ assert.match(html, /join as a member/i, 'and the form says what they will join as instead');
260
+ });
261
+
262
+ await test('the dropdown excludes members AND anyone already pending', async () => {
263
+ const h = await openInviteForm(harness({
264
+ pendingRequests: [{ id: 55, kind: 'invite', builder_id: PENDING_ID, display_name: 'Ben Pending' }],
265
+ }));
266
+ const html = h.getEl('goal-invite').innerHTML;
267
+ assert.ok(html.includes(`value="${FREE_ID}"`), 'the invitable builder is offered');
268
+ assert.ok(!html.includes(`value="${MEMBER_ID}"`), 'a member would 409 already_a_member');
269
+ // The one that matters: a pending invitation is INVISIBLE in the member list,
270
+ // so without this filter the user picks an ordinary-looking name and is handed
271
+ // an error they had no way to predict.
272
+ assert.ok(!html.includes(`value="${PENDING_ID}"`), 'a pending invitee would 409 invite_pending');
273
+ });
274
+
275
+ await test('a pending JOIN-REQUEST is excluded too, not just an invitation', async () => {
276
+ const h = await openInviteForm(harness({
277
+ pendingRequests: [{ id: 56, kind: 'request', builder_id: PENDING_ID }],
278
+ }));
279
+ const html = h.getEl('goal-invite').innerHTML;
280
+ assert.ok(!html.includes(`value="${PENDING_ID}"`),
281
+ 'the server refuses on EITHER kind, so the filter may not look only at invites');
282
+ });
283
+
284
+ await test('the force-add survives, one click away, and says nobody was asked', async () => {
285
+ const h = await openInviteForm(harness());
286
+ const collapsed = h.getEl('goal-invite').innerHTML;
287
+ assert.match(collapsed, /without asking/i, 'the path is discoverable, not removed');
288
+ assert.ok(!/id="goal-direct-form"/.test(collapsed), 'but it is not the default control');
289
+
290
+ h.getEl('goal-direct-open').fire('click');
291
+ await tick();
292
+ const open = h.getEl('goal-invite').innerHTML;
293
+ assert.match(open, /id="goal-direct-kind"/, 'the membership kind IS honoured on this path, so it is offered here');
294
+
295
+ h.getEl('goal-direct-builder').value = String(FREE_ID);
296
+ h.getEl('goal-direct-kind').value = 'lead';
297
+ h.getEl('goal-direct-form').fire('submit');
298
+ await tick(); await tick();
299
+
300
+ assert.equal(h.posts.length, 1);
301
+ assert.match(h.posts[0].url, /\/goals\/1\/members$/, 'the force-add endpoint');
302
+ assert.equal(h.posts[0].body.membership_kind, 'lead', 'and the chosen kind travels');
303
+ const said = h.toasts.join(' ');
304
+ assert.match(said, /without being asked/i, 'the consequence is stated, not hidden behind "Invited."');
305
+ });
306
+
307
+ summary();