@7365admin1/core 3.65.2 → 3.67.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.
- package/CHANGELOG.md +41 -0
- package/dist/index.d.ts +297 -6
- package/dist/index.js +5609 -5259
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +2448 -2108
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
- package/test/backfill-org-owner-roles.test.mjs +214 -1
- package/test/console-permission.test.mjs +4 -2
- package/test/e2e/harness.mjs +5 -0
- package/test/e2e/org-owner-repair.e2e.test.mjs +469 -0
- package/test/notification-access.test.mjs +35 -6
- package/test/org-owner-repair.test.mjs +726 -0
- package/test/staff-console-authz.test.mjs +12 -0
- package/tools/backfill-org-owner-roles/backfill.mjs +253 -11
- package/tools/backfill-org-owner-roles/dry-run-devtools-snippet.js +10 -2
- package/tools/backfill-org-owner-roles/repair-owner-membership-devtools-snippet.js +175 -0
|
@@ -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
|
|
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
|
-
* - **
|
|
19
|
-
* `POST /api/roles`. It never
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
|
177
|
-
|
|
178
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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=
|
|
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
|
+
})();
|