@7365admin1/core 3.65.2 → 3.66.0

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.
@@ -129,6 +129,18 @@ const GUARDED = [
129
129
  "requireConsolePermission",
130
130
  "orgModulePreview(",
131
131
  ],
132
+ // Repairing an organisation whose owner membership was never written. Writing
133
+ // a membership for somebody else is a console operation, so it takes the staff
134
+ // gate plus the `organizations` module — and nothing about WHO gets the row
135
+ // comes from the request: the account is derived server-side from the
136
+ // organisation's own registered address (`hasOrgOwnership`). See
137
+ // org-owner-repair.test.mjs and its e2e twin.
138
+ [
139
+ "organization.controller.ts",
140
+ "repairOwnerMembership",
141
+ "requireConsolePermission",
142
+ "createMemberDirect(",
143
+ ],
132
144
  ["organization-v2.controller.ts", "getAll", "requireConsolePermission", "_getAll("],
133
145
  [
134
146
  "organization-v2.controller.ts",
@@ -1,7 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
3
  * GIVE EVERY ROLE-LESS ORGANISATION THE TWO OWNER ROLES A PAID SIGNUP ALREADY
4
- * GETS -- through the ordinary authenticated API, never the database.
4
+ * GETS, AND EVERY OWNER-LESS ORGANISATION ITS OWNER MEMBERSHIP -- through the
5
+ * ordinary authenticated API, never the database.
5
6
  *
6
7
  * ## Why this exists
7
8
  *
@@ -15,9 +16,35 @@
15
16
  *
16
17
  * ## What it will not do
17
18
  *
18
- * - **INSERT ONLY.** The only request it ever sends that is not a GET is
19
- * `POST /api/roles`. It never updates, deletes or repoints a role, member,
20
- * invitation or user.
19
+ * - **It only ever ADDS.** The only requests it sends that are not a GET are
20
+ * `POST /api/roles` and `POST /api/members/direct`. It never deletes or
21
+ * repoints a role, member, invitation or user. Two server-side side effects
22
+ * ride along with the membership, so "insert only" would overstate it:
23
+ * `createMemberDirect` also points the owner's `defaultOrg` at this
24
+ * organisation and completes any pending invitation for that address.
25
+ * - **A dry run really does write nothing** -- but only because it does not
26
+ * list an organisation's roles. `GET /api/roles` SELF-HEALS: a whole-org,
27
+ * unsearched, page-1 list that comes back empty seeds the two owner roles
28
+ * (that is how 6 roles appeared across 3 production organisations on
29
+ * 2026-09-09). The owner role is therefore resolved under `--apply` only.
30
+ *
31
+ * ## The second half: the owner membership
32
+ *
33
+ * An organisation created through `POST /api/organizations/onboarding` before
34
+ * 2026-09-08 got no `members` row of its own. Until 3.60.0 that row was written
35
+ * by the wizard's step 1, which is skippable -- so an owner who skipped it, or
36
+ * whose browser dropped the call, ended up owning an organisation they cannot
37
+ * open: `GET /api/members/user/:id/app/organization` answers 404, and every
38
+ * console lands them back on "create an organisation". Measured on production
39
+ * 2026-09-09: JLL Singapore (31 Aug) and two QA organisations are in exactly
40
+ * that state, and no organisation created since has been -- the server now
41
+ * writes the row with the organisation itself.
42
+ *
43
+ * Who the owner is, is not guessed. It is the same rule the server's own
44
+ * `hasOrgOwnership` uses to let that person reach the organisation at all: the
45
+ * account whose e-mail address the organisation is registered under. An
46
+ * organisation whose e-mail resolves to no account, or that already holds an
47
+ * `organization` membership, is skipped.
21
48
  * - **No database.** It holds no connection string and speaks only to the API,
22
49
  * so every write is authorised, rate-limited and audited exactly like a write
23
50
  * from the console.
@@ -72,6 +99,8 @@ export const APP_SERVICE_TYPES = [
72
99
  "pool_maintenance_services",
73
100
  ];
74
101
 
102
+ import { pathToFileURL } from "node:url";
103
+
75
104
  const HEX24 = /^[0-9a-fA-F]{24}$/;
76
105
 
77
106
  /**
@@ -140,7 +169,69 @@ export function planForOrg(org, existingRoleCount) {
140
169
  return { payloads };
141
170
  }
142
171
 
143
- // ─── everything below is I/O ────────────────────────────────────────────────
172
+ /**
173
+ * The owner membership this organisation is missing, if it is missing one.
174
+ *
175
+ * Pure, for the same reason `planForOrg` is: the decision is what the tests
176
+ * exercise, and no decision is taken in the I/O below.
177
+ *
178
+ * `type: "organization"` is not a detail. It is the exact row every console
179
+ * landing page reads (`GET /api/members/user/:id/app/organization`), so a row
180
+ * of any other type leaves the owner locked out just the same.
181
+ *
182
+ * @returns `{ skip: <reason> }` or `{ payload: {...} }`
183
+ */
184
+ export function planOwnerMembership(org, orgMemberCount, owner, ownerRoleId) {
185
+ const id = org?._id?.toString() ?? "";
186
+ if (!HEX24.test(id)) return { skip: "organisation has no usable id" };
187
+
188
+ if (!Number.isInteger(orgMemberCount) || orgMemberCount < 0) {
189
+ // Not knowing is not the same as knowing there are none. Fail closed.
190
+ return { skip: "existing member count unknown" };
191
+ }
192
+
193
+ // Somebody already belongs to it. Never a second owner.
194
+ if (orgMemberCount > 0) return { skip: `already has ${orgMemberCount} organization member(s)` };
195
+
196
+ const userId = owner?._id?.toString() ?? "";
197
+ if (!HEX24.test(userId)) return { skip: "no account holds this organisation's e-mail address" };
198
+
199
+ const roleId = ownerRoleId?.toString() ?? "";
200
+ if (!HEX24.test(roleId)) return { skip: "organisation has no `organization` owner role to point at" };
201
+
202
+ return {
203
+ payload: {
204
+ userId,
205
+ orgId: id,
206
+ roleId,
207
+ app: "organization",
208
+ // What `POST /api/organizations/onboarding` writes for a brand-new owner.
209
+ // The onboarding wizard is still ahead of them, so it stays true here.
210
+ onboardingRequired: true,
211
+ },
212
+ };
213
+ }
214
+
215
+ /**
216
+ * The TOTAL out of a `pageRange` string, or -1 for "could not tell".
217
+ *
218
+ * Both counters below fail CLOSED on -1, so this is the guard that decides
219
+ * whether an organisation is repaired at all -- and it used to fail OPEN.
220
+ * `Number("".split(" of ").pop())` is `0`, and `Number.isInteger(0)` is true,
221
+ * so an ABSENT or empty `pageRange` -- a shape change, an error body that still
222
+ * parses, a field that stops being sent -- read as "this organisation has no
223
+ * owner" and it would have been written to.
224
+ *
225
+ * A genuine empty page is NOT that case and must still repair: the API answers
226
+ * `"0-0 of 0"` for it (measured against the live API, not assumed), which has a
227
+ * digit and parses to 0. Only a string with no total at all returns -1.
228
+ */
229
+ export function totalFromPageRange(pageRange) {
230
+ const match = / of ([0-9]+)$/.exec(String(pageRange ?? "").trim());
231
+ return match ? Number(match[1]) : -1;
232
+ }
233
+
234
+ // ─── everything below is I/O ──────────────────────────────────
144
235
 
145
236
  const API = (process.env.API ?? "").replace(/\/+$/, "");
146
237
  const SID = process.env.SEVEN365_SID ?? "";
@@ -171,11 +262,75 @@ async function allOrganizations() {
171
262
  return out;
172
263
  }
173
264
 
174
- /** How many ACTIVE roles this organisation holds. `GET /roles` lists only active rows. */
265
+ /**
266
+ * How many ACTIVE roles this organisation holds -- counted WITHOUT tripping the
267
+ * self-heal. `GET /roles` lists only active rows, so a soft-deleted role reads
268
+ * as none.
269
+ *
270
+ * `page=2`, not `page=1`, and that is the whole point. `role.controller`
271
+ * (:399-406) treats a whole-org, unsearched, site-less, **page-1** list that
272
+ * comes back empty as a repair request and SEEDS the two owner roles. That is
273
+ * exactly the organisations this tool is about to plan for, so reading page 1
274
+ * here created the roles it then reported as "0 to add" -- a dry run that wrote.
275
+ * (It is worse than it looks: the repository skips `page * limit`, so with
276
+ * `limit=1&page=1` even an organisation holding ONE role comes back empty and
277
+ * heals.) Page 2 cannot heal, and `pageRange` still carries the true total: it
278
+ * is built as `${start}-${end} of ${length}` from `countDocuments`, whatever
279
+ * page was asked for.
280
+ */
175
281
  async function roleCount(orgId) {
176
- const { pageRange = "" } = await api(`/api/roles?org=${orgId}&limit=1&page=1`);
177
- const total = Number(pageRange.split(" of ").pop());
178
- return Number.isInteger(total) ? total : -1;
282
+ const { pageRange } = await api(`/api/roles?org=${orgId}&limit=1&page=2`);
283
+ return totalFromPageRange(pageRange);
284
+ }
285
+
286
+ /**
287
+ * How many `organization` memberships this organisation holds, ACTIVE or
288
+ * SUSPENDED.
289
+ *
290
+ * `GET /api/members` requires both `type` and `status`, and `organization` is
291
+ * the only type the console's landing page asks for -- so this counts exactly
292
+ * the row whose absence is the defect. Both statuses are counted because a
293
+ * deliberately SUSPENDED owner row still means this organisation has an owner:
294
+ * counting only `active` would read it as owner-less and write a second
295
+ * membership beside it, and `createMemberDirect` does not de-duplicate. A
296
+ * `deleted` row is genuinely gone and is not counted. -1 means "could not
297
+ * tell", which `planOwnerMembership` fails closed on.
298
+ */
299
+ async function orgMemberCount(orgId) {
300
+ let total = 0;
301
+ for (const status of ["active", "suspended"]) {
302
+ const { pageRange } = await api(
303
+ `/api/members?org=${orgId}&type=organization&status=${status}&limit=1&page=1`,
304
+ );
305
+ const count = totalFromPageRange(pageRange);
306
+ if (count < 0) return -1;
307
+ total += count;
308
+ }
309
+ return total;
310
+ }
311
+
312
+ /** The account the organisation's e-mail address belongs to, or null. */
313
+ async function userByEmail(email) {
314
+ const address = (email ?? "").toString().trim();
315
+ if (!address) return null;
316
+ return await api(`/api/users/email/${encodeURIComponent(address)}`).catch(() => null);
317
+ }
318
+
319
+ /**
320
+ * The organisation's own `organization` owner role, or "".
321
+ *
322
+ * `type` is a SERVER-SIDE filter here, not a convenience: `GET /api/roles`
323
+ * does not honour `limit`. Asked for an organisation holding two roles with
324
+ * `limit=100` it answers one item and `pages: 2` -- so scanning "the list"
325
+ * silently reads one page and can miss the very role this points the membership
326
+ * at. Filtering on the server returns the role itself, whatever the paging does.
327
+ */
328
+ async function orgOwnerRoleId(orgId) {
329
+ const { items = [] } = await api(
330
+ `/api/roles?org=${orgId}&type=organization&limit=100&page=1`,
331
+ ).catch(() => ({}));
332
+ const owner = items.find((role) => String(role?.name ?? "").toLowerCase() === "owner");
333
+ return owner?._id?.toString() ?? "";
179
334
  }
180
335
 
181
336
  async function main() {
@@ -203,8 +358,16 @@ async function main() {
203
358
  console.log(` ${org._id} ${org.name} [${org.nature ?? "(no nature)"}] -> ${payloads.map((p) => p.type).join(" + ")}`);
204
359
  }
205
360
 
361
+ const pendingRole = new Set(planned.map(({ org }) => org._id.toString()));
362
+
206
363
  if (!APPLY) {
207
- console.log("\nDRY RUN. Nothing was written. Re-run with --apply to write.");
364
+ await reportMemberships(orgs);
365
+ console.log(
366
+ "\nDRY RUN. Nothing was written. The owner role is deliberately NOT looked up:" +
367
+ "\na whole-org `GET /api/roles` list that comes back empty SEEDS the two owner roles," +
368
+ "\nso resolving it here would write. `--apply` resolves it after the role pass instead." +
369
+ "\nRe-run with --apply to write.",
370
+ );
208
371
  return;
209
372
  }
210
373
 
@@ -238,9 +401,88 @@ async function main() {
238
401
  for (const p of partial) console.log(` ${p.org._id} ${p.org.name}: wrote ${p.wrote}/${p.of} -- ${p.error}`);
239
402
  process.exitCode = 1;
240
403
  }
404
+ // SECOND, and only now: the owner membership. It points at the `organization`
405
+ // owner role, which for a role-less organisation is the one the loop above
406
+ // has just created -- so this cannot run before it.
407
+ let members = 0;
408
+ const refused = [];
409
+
410
+ for (const org of orgs) {
411
+ const id = org?._id?.toString() ?? "";
412
+ const plan = planOwnerMembership(
413
+ org,
414
+ await orgMemberCount(id),
415
+ await userByEmail(org?.email),
416
+ await orgOwnerRoleId(id),
417
+ );
418
+ if (!plan.payload) {
419
+ // Say why. The dry run judges the role at `--apply` time, so an
420
+ // organisation that holds roles but none named `owner` is reported as "to
421
+ // add" and would otherwise vanish here without a word.
422
+ if (plan.skip && !/already has/.test(plan.skip)) refused.push({ org, error: plan.skip });
423
+ continue;
424
+ }
425
+
426
+ try {
427
+ await api("/api/members/direct", { method: "POST", body: JSON.stringify(plan.payload) });
428
+ members++;
429
+ console.log(` MEMBER ${org._id} ${org.name} -> ${org.email}`);
430
+ } catch (error) {
431
+ refused.push({ org, error: error.message });
432
+ }
433
+ }
434
+
435
+ console.log(`\nowner memberships written: ${members}`);
436
+ if (refused.length) {
437
+ console.log(`\nREFUSED -- ${refused.length} organisation(s) still have no owner:`);
438
+ for (const r of refused) console.log(` ${r.org._id} ${r.org.name}: ${r.error}`);
439
+ process.exitCode = 1;
440
+ }
441
+ }
442
+
443
+ /**
444
+ * Dry-run half of the membership pass: says what it would do, writes nothing.
445
+ *
446
+ * It deliberately does NOT look the owner role up. `GET /api/roles` is not a
447
+ * read: `role.controller` self-heals a whole-org, unsearched, page-1 list that
448
+ * comes back empty by SEEDING the two owner roles -- 6 roles were created across
449
+ * 3 production organisations that way on 2026-09-09, by a run that reported
450
+ * "nothing was written". `--apply` resolves the role after the role pass, where
451
+ * a seed is the intended outcome anyway.
452
+ */
453
+ async function reportMemberships(orgs) {
454
+ const would = [];
455
+ const skipped = [];
456
+ // A stand-in id, never sent, so the plan's OTHER guards -- member count, the
457
+ // owner account, a malformed organisation -- still decide the answer here.
458
+ const AT_APPLY = "0".repeat(24);
459
+
460
+ for (const org of orgs) {
461
+ const id = org?._id?.toString() ?? "";
462
+ const plan = planOwnerMembership(org, await orgMemberCount(id), await userByEmail(org?.email), AT_APPLY);
463
+
464
+ if (plan.payload) {
465
+ would.push({ org });
466
+ } else if (!/already has/.test(plan.skip)) {
467
+ skipped.push({ org, why: plan.skip });
468
+ }
469
+ }
470
+
471
+ console.log(`\nowner memberships to add : ${would.length}`);
472
+ for (const { org } of would) {
473
+ console.log(` ${org._id} ${org.name} -> ${org.email} role (resolved at --apply)`);
474
+ }
475
+ if (skipped.length) {
476
+ console.log(`\nowner-less but NOT repairable automatically (${skipped.length}):`);
477
+ for (const s of skipped) console.log(` ${s.org._id} ${s.org.name}: ${s.why}`);
478
+ }
241
479
  }
242
480
 
243
- if (import.meta.url === `file://${process.argv[1]?.replace(/\\/g, "/")}`) {
481
+ // Windows: `file://${path}` builds file://C:/... while import.meta.url is
482
+ // file:///C:/... , so the hand-rolled comparison never matched and the tool
483
+ // exited 0 having done nothing at all -- which reads exactly like "no work to
484
+ // do". `pathToFileURL` is the platform's own answer and is correct on both.
485
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
244
486
  main().catch((error) => {
245
487
  console.error(error.message);
246
488
  process.exit(1);
@@ -2,7 +2,11 @@
2
2
  * DRY RUN ONLY -- paste into the DevTools console of a SIGNED-IN Seven365
3
3
  * platform-staff session on https://org.app.iservice365.org
4
4
  *
5
- * GETs only. It writes nothing, anywhere. It sends no Authorization header and
5
+ * GETs only -- and it counts roles with `page=2` on purpose, because
6
+ * `GET /api/roles` is NOT a read: `role.controller` seeds an organisation's two
7
+ * owner roles when a whole-org, unsearched, **page-1** list comes back empty.
8
+ * Reading page 1 here would write to exactly the organisations this reports on.
9
+ * It sends no Authorization header and
6
10
  * puts no secret in a URL: the session rides as the `sid` cookie the app
7
11
  * already set, which `requireAuth` reads first (node-server-utils
8
12
  * authorization.middleware).
@@ -36,8 +40,12 @@
36
40
 
37
41
  // 2. how many ACTIVE roles each one holds -- `GET /roles` lists only active
38
42
  // rows, so a soft-deleted role reads as none. Five at a time, to be polite.
43
+ // `page=2`: a whole-org page-1 list that comes back EMPTY makes the server
44
+ // seed the two owner roles, so page 1 would write to the very
45
+ // organisations this snippet exists to count. The total in `pageRange` is
46
+ // `countDocuments`, so it is the same number whichever page is asked for.
39
47
  const roleCount = async (id) => {
40
- const { pageRange = "" } = await get(`/api/roles?org=${id}&limit=1&page=1`);
48
+ const { pageRange = "" } = await get(`/api/roles?org=${id}&limit=1&page=2`);
41
49
  return Number(pageRange.split(" of ").pop());
42
50
  };
43
51
 
@@ -0,0 +1,175 @@
1
+ /**
2
+ * REPAIR THE OWNER-LESS ORGANISATIONS -- paste into the DevTools console of a
3
+ * SIGNED-IN Seven365 platform-staff session on https://org.app.iservice365.org
4
+ *
5
+ * Same job as `backfill.mjs`, same rules, no terminal and no session id to
6
+ * copy anywhere: the session rides as the `sid` cookie the app already set.
7
+ *
8
+ * ## What it repairs
9
+ *
10
+ * An organisation created through the onboarding wizard before 2026-09-08 was
11
+ * written with NO `members` row of its own -- the row was the wizard's step 1,
12
+ * which is skippable. The owner is then locked out of the organisation they
13
+ * created: `GET /api/members/user/:id/app/organization` answers 404 and every
14
+ * console offers to create an organisation instead of opening theirs.
15
+ *
16
+ * ## What it will not do
17
+ *
18
+ * - **It only ever ADDS** -- `POST /api/roles` and `POST /api/members/direct`.
19
+ * It never deletes or repoints anything. Two server-side side effects ride
20
+ * along with the membership, though: `createMemberDirect` also points the
21
+ * owner's `defaultOrg` at this organisation and completes any pending
22
+ * invitation for that address.
23
+ * - **ACTIVE organisations only.** A suspended or deleted organisation is left
24
+ * exactly as it is.
25
+ * - **Never a second owner.** An organisation that already holds an `active` OR
26
+ * `suspended` `organization` membership is skipped, so a re-run writes
27
+ * nothing and a deliberately suspended owner is never duplicated.
28
+ * - **The dry run writes nothing** -- and to keep that true it does not list an
29
+ * organisation's roles. `GET /api/roles` SELF-HEALS: a whole-org, unsearched,
30
+ * page-1 list that comes back empty seeds the two owner roles. The role is
31
+ * resolved in the APPLY pass only.
32
+ * - **Never guesses who the owner is.** It is the account whose e-mail address
33
+ * the organisation is registered under -- the same rule the server's own
34
+ * `hasOrgOwnership` uses to let that person reach the organisation at all.
35
+ * No account, no repair.
36
+ *
37
+ * ## How to run it
38
+ *
39
+ * 1. Paste as-is. `APPLY` is false, so it only prints the plan.
40
+ * 2. Read the plan. Then change `APPLY` to true and paste again to write.
41
+ */
42
+ (async () => {
43
+ const APPLY = false;
44
+
45
+ const APP_SERVICE_TYPES = [
46
+ "real_estate_developer", "property_management_agency", "security_agency",
47
+ "cleaning_services", "mechanical_electrical_services", "landscaping_services",
48
+ "pest_control_services", "pool_maintenance_services",
49
+ ];
50
+
51
+ const call = async (path, init) => {
52
+ const res = await fetch(path, {
53
+ credentials: "include",
54
+ headers: { Accept: "application/json", "Content-Type": "application/json" },
55
+ ...init,
56
+ });
57
+ const text = await res.text();
58
+ if (!res.ok) throw new Error(`${init?.method ?? "GET"} ${path} -> ${res.status} ${text.slice(0, 200)}`);
59
+ return text ? JSON.parse(text) : {};
60
+ };
61
+ const post = (path, body) => call(path, { method: "POST", body: JSON.stringify(body) });
62
+ const ownerRolePayloads = (org) =>
63
+ [{ org: org._id, name: "owner", permissions: ["*"], type: "organization" }].concat(
64
+ APP_SERVICE_TYPES.includes(org.nature)
65
+ ? [{ org: org._id, name: "owner", permissions: ["*"], type: org.nature }]
66
+ : [],
67
+ );
68
+
69
+ // 1. every organisation this session can see
70
+ const orgs = [];
71
+ for (let page = 1; page < 100; page++) {
72
+ const { items = [], pages = 1 } = await call(`/api/organizations?page=${page}&limit=100`);
73
+ orgs.push(...items);
74
+ if (page >= pages || !items.length) break;
75
+ }
76
+ if (orgs.length < 100) {
77
+ console.warn("Fewer than 100 organisations visible -- this session is probably NOT platform staff, so this is a subset, not the fleet.");
78
+ }
79
+
80
+ // 2. plan. Active organisations only, and only the ones nobody belongs to.
81
+ const plan = [];
82
+ const skipped = [];
83
+
84
+ for (const org of orgs.filter((o) => o.status === "active")) {
85
+ const id = org._id;
86
+ try {
87
+ // ACTIVE and SUSPENDED, added: the API requires a status, and a suspended
88
+ // owner row still means this organisation HAS an owner -- writing beside
89
+ // it would duplicate a membership somebody suspended on purpose.
90
+ // Strict: `Number("".split(" of ").pop())` is 0, so an absent or empty
91
+ // pageRange used to read as "no owner" and this would have written to it.
92
+ // A genuinely empty page is "0-0 of 0" and still parses to 0.
93
+ let members = 0;
94
+ let unreadable;
95
+ for (const status of ["active", "suspended"]) {
96
+ const { pageRange } = await call(`/api/members?org=${id}&type=organization&status=${status}&limit=1&page=1`);
97
+ const match = / of ([0-9]+)$/.exec(String(pageRange ?? "").trim());
98
+ if (!match) { unreadable = pageRange; break; }
99
+ members += Number(match[1]);
100
+ }
101
+ if (unreadable !== undefined) { skipped.push({ id, name: org.name, why: `member count unreadable (pageRange: ${JSON.stringify(unreadable)})` }); continue; }
102
+ if (members > 0) continue;
103
+
104
+ const owner = org.email
105
+ ? await call(`/api/users/email/${encodeURIComponent(org.email)}`).catch(() => null)
106
+ : null;
107
+ if (!owner?._id) { skipped.push({ id, name: org.name, why: `no account holds ${org.email || "(no e-mail)"}` }); continue; }
108
+
109
+ // The owner role is NOT looked up here: that list read seeds the two
110
+ // owner roles when the organisation has none, which would make this dry
111
+ // run a write. The APPLY pass resolves it instead.
112
+ plan.push({ id, name: org.name, nature: org.nature, email: org.email, userId: owner._id, org });
113
+ } catch (error) {
114
+ skipped.push({ id, name: org.name, why: error.message });
115
+ }
116
+ }
117
+
118
+ console.log(APPLY ? "=== APPLY -- this WILL write ===" : "=== DRY RUN -- nothing was written ===");
119
+ console.log("active organisations with NO owner:", plan.length);
120
+ console.table(plan.map((p) => ({
121
+ org: p.id, name: p.name, owner: p.email, userId: p.userId,
122
+ ownerRole: "(resolved at APPLY)",
123
+ })));
124
+ if (skipped.length) console.warn("owner-less but NOT repairable automatically:", skipped);
125
+
126
+ if (!APPLY) {
127
+ window.__ownerRepairPlan = plan;
128
+ console.log("Set APPLY = true and paste again to write. Plan also on window.__ownerRepairPlan");
129
+ return;
130
+ }
131
+
132
+ let roles = 0, members = 0;
133
+ const failed = [];
134
+
135
+ for (const p of plan) {
136
+ try {
137
+ // Resolve the owner role HERE. `type` is filtered SERVER-SIDE because
138
+ // `GET /api/roles` does not honour `limit`: an organisation with two roles
139
+ // answers one item and pages: 2, so scanning a page can miss the role the
140
+ // membership must point at. This same read seeds the two owner roles when
141
+ // the organisation has none -- intended under APPLY, which is why the dry
142
+ // run above never makes it.
143
+ const { items: existing = [] } = await call(`/api/roles?org=${p.id}&type=organization&limit=100&page=1`);
144
+ if (!existing.find((r) => String(r.name).toLowerCase() === "owner")) {
145
+ for (const payload of ownerRolePayloads(p.org)) {
146
+ await post("/api/roles", payload);
147
+ roles++;
148
+ }
149
+ }
150
+
151
+ // Read the role id back from the LIST rather than from the create
152
+ // response: the membership points at it, and a wrong id is a member with
153
+ // somebody else's permissions. The list is the same query the console
154
+ // reads, so it is the shape that is actually pinned by a caller.
155
+ const { items: after = [] } = await call(`/api/roles?org=${p.id}&type=organization&limit=100&page=1`);
156
+ const roleId = (after.find((r) => String(r.name).toLowerCase() === "owner") || {})._id;
157
+ if (!roleId) throw new Error("no organization owner role to point at");
158
+
159
+ await post("/api/members/direct", {
160
+ userId: p.userId,
161
+ orgId: p.id,
162
+ roleId: String(roleId),
163
+ app: "organization",
164
+ onboardingRequired: true,
165
+ });
166
+ members++;
167
+ console.log(` REPAIRED ${p.name} -> ${p.email}`);
168
+ } catch (error) {
169
+ failed.push({ org: p.id, name: p.name, error: error.message });
170
+ }
171
+ }
172
+
173
+ console.log(`roles created: ${roles} owner memberships created: ${members}`);
174
+ if (failed.length) console.error("STILL OWNER-LESS:", failed);
175
+ })();