@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.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@7365admin1/core",
3
3
  "license": "MIT",
4
- "version": "3.65.2",
4
+ "version": "3.66.0",
5
5
  "author": "7365admin1",
6
6
  "main": "dist/index.js",
7
7
  "module": "dist/index.mjs",
@@ -13,7 +13,7 @@
13
13
  "build": "tsup src/index.ts --format cjs,esm --dts && shx cp -r src/public dist && shx cp -r src/utils/handlebars dist",
14
14
  "release": "yarn run build && changeset publish",
15
15
  "lint": "tsc",
16
- "test": "tsup src/config.ts src/utils/anpr-revoke.util.ts src/utils/vehicle-request.util.ts src/utils/camera-view.util.ts src/utils/camera-bulk-import.util.ts src/utils/dashboard-metrics.util.ts src/utils/camera-capability.util.ts src/services/camera-device-http.service.ts src/utils/camera-alert.util.ts src/utils/dahua-protocol.util.ts src/utils/site-name.util.ts src/utils/service-provider-invite.util.ts src/utils/notification-category.util.ts src/utils/hid-card.util.ts src/utils/hid-json.util.ts src/utils/hid-access.util.ts src/models/customer-site.model.ts src/models/site-camera.model.ts src/models/member.model.ts src/models/subscription.model.ts src/utils/session-id.util.ts src/utils/occurrence-query.util.ts src/utils/update-result.util.ts src/utils/hid-amico-authz.util.ts src/utils/promo-code-currency.util.ts src/utils/console-audit.util.ts src/utils/org-suspension.util.ts src/utils/session-revoke.util.ts src/models/hid-amico.model.ts src/models/patrol-email.model.ts src/utils/patrol-email-pdf.util.ts src/utils/visitor-status.util.ts src/utils/self-service-email.util.ts src/utils/hid-secret.util.ts src/models/visitor-invite.model.ts src/models/guard-invite.model.ts src/utils/site-timezone.util.ts src/utils/bulletin-service-apps.util.ts src/models/bulletin-board.model.ts src/utils/vehicle-import.util.ts src/utils/expired-vehicle-sweep.util.ts src/models/vehicle.model.ts src/controllers/chat.controller.ts src/utils/file-owner.util.ts src/utils/console-permission.util.ts src/utils/role-scope.util.ts src/utils/org-default-roles.util.ts src/utils/org-modules.util.ts src/utils/site-modules.util.ts src/utils/resident-app-modules.util.ts src/utils/resident-default-role.util.ts src/utils/invite-role-scope.util.ts src/models/personal-emergency-chain.model.ts src/utils/self-signup-files.util.ts src/models/person.model.ts src/utils/module-gate.util.ts src/utils/member-lookup.util.ts src/utils/notification-access.util.ts src/utils/module-access.util.ts src/services/module-gate.service.ts src/controllers/site.controller.ts src/controllers/organization.controller.ts src/controllers/member.controller.ts --format esm --out-dir test/.build --no-dts --silent && node --test test/*.test.mjs",
16
+ "test": "tsup src/config.ts src/utils/anpr-revoke.util.ts src/utils/vehicle-request.util.ts src/utils/camera-view.util.ts src/utils/camera-bulk-import.util.ts src/utils/dashboard-metrics.util.ts src/utils/camera-capability.util.ts src/services/camera-device-http.service.ts src/utils/camera-alert.util.ts src/utils/dahua-protocol.util.ts src/utils/site-name.util.ts src/utils/service-provider-invite.util.ts src/utils/notification-category.util.ts src/utils/hid-card.util.ts src/utils/hid-json.util.ts src/utils/hid-access.util.ts src/models/customer-site.model.ts src/models/site-camera.model.ts src/models/member.model.ts src/models/subscription.model.ts src/utils/session-id.util.ts src/utils/occurrence-query.util.ts src/utils/update-result.util.ts src/utils/hid-amico-authz.util.ts src/utils/promo-code-currency.util.ts src/utils/console-audit.util.ts src/utils/org-suspension.util.ts src/utils/session-revoke.util.ts src/models/hid-amico.model.ts src/models/patrol-email.model.ts src/utils/patrol-email-pdf.util.ts src/utils/visitor-status.util.ts src/utils/self-service-email.util.ts src/utils/hid-secret.util.ts src/models/visitor-invite.model.ts src/models/guard-invite.model.ts src/utils/site-timezone.util.ts src/utils/bulletin-service-apps.util.ts src/models/bulletin-board.model.ts src/utils/vehicle-import.util.ts src/utils/expired-vehicle-sweep.util.ts src/models/vehicle.model.ts src/controllers/chat.controller.ts src/utils/file-owner.util.ts src/utils/console-permission.util.ts src/utils/role-scope.util.ts src/utils/org-default-roles.util.ts src/utils/org-modules.util.ts src/utils/site-modules.util.ts src/utils/resident-app-modules.util.ts src/utils/resident-default-role.util.ts src/utils/invite-role-scope.util.ts src/models/personal-emergency-chain.model.ts src/utils/self-signup-files.util.ts src/models/person.model.ts src/utils/module-gate.util.ts src/utils/member-lookup.util.ts src/utils/notification-access.util.ts src/utils/module-access.util.ts src/services/module-gate.service.ts src/controllers/site.controller.ts src/controllers/organization.controller.ts src/controllers/member.controller.ts src/utils/org-owner-repair.util.ts --format esm --out-dir test/.build --no-dts --silent && node --test test/*.test.mjs",
17
17
  "test:camera": "yarn test",
18
18
  "test:e2e": "yarn build && node --test --test-concurrency=1 --test-timeout=600000 \"test/e2e/*.test.mjs\""
19
19
  },
@@ -14,9 +14,10 @@
14
14
  */
15
15
 
16
16
  import { strict as assert } from "node:assert";
17
+ import { readFileSync } from "node:fs";
17
18
  import test from "node:test";
18
19
 
19
- import { planForOrg, APP_SERVICE_TYPES } from "../tools/backfill-org-owner-roles/backfill.mjs";
20
+ import { planForOrg, planOwnerMembership, totalFromPageRange, APP_SERVICE_TYPES } from "../tools/backfill-org-owner-roles/backfill.mjs";
20
21
  import { defaultOwnerRoles } from "./.build/utils/org-default-roles.util.mjs";
21
22
  import { MEMBER_TYPES } from "./.build/models/member.model.mjs";
22
23
 
@@ -164,3 +165,215 @@ test("a second run over the same organisations plans nothing", () => {
164
165
  assert.equal(first.reduce((n, p) => n + p.payloads.length, 0), APP_SERVICE_TYPES.length * 4);
165
166
  assert.equal(second.reduce((n, p) => n + (p.payloads?.length ?? 0), 0), 0);
166
167
  });
168
+
169
+ // ─── THE OWNER MEMBERSHIP ───────────────────────────────────────────────────
170
+ //
171
+ // The second half of the tool. Same discipline: the tool decides one thing --
172
+ // "what membership is this organisation missing" -- and that decision is what
173
+ // is tested. Every case has a positive control beside it, because a planner
174
+ // that refused everything would otherwise pass "never writes a second owner"
175
+ // and "fails closed" perfectly while repairing nothing.
176
+
177
+ const USER = "6a951ce503be44033d434f67";
178
+ const ROLE = "6aa0c7308d25d0ba14fc5c12";
179
+ const owner = (extra = {}) => ({ _id: USER, email: "owner@example.com", ...extra });
180
+
181
+ test("the row it plans is the one the console's landing page reads", () => {
182
+ const plan = planOwnerMembership(org({ email: "owner@example.com" }), 0, owner(), ROLE);
183
+
184
+ assert.deepEqual(plan.payload, {
185
+ userId: USER,
186
+ orgId: ORG,
187
+ roleId: ROLE,
188
+ app: "organization",
189
+ onboardingRequired: true,
190
+ });
191
+
192
+ // `GET /api/members/user/:id/app/organization` is the query. A row of any
193
+ // other type leaves the owner locked out exactly as they are now, so the
194
+ // string is the contract and not a default.
195
+ assert.equal(plan.payload.app, "organization");
196
+ assert.ok(MEMBER_TYPES.includes(plan.payload.app));
197
+
198
+ // POSITIVE CONTROL: the app-typed row the wizard writes for a SITE person is
199
+ // not this row, and must not satisfy the assertion above.
200
+ assert.throws(() => assert.equal("property_management_agency", "organization"));
201
+ });
202
+
203
+ test("an organisation that already has an owner is skipped, with no exceptions", () => {
204
+ for (const count of [1, 2, 9, 146]) {
205
+ const plan = planOwnerMembership(org(), count, owner(), ROLE);
206
+ assert.equal(plan.payload, undefined, `count ${count} must plan nothing`);
207
+ assert.match(plan.skip, /already has/);
208
+ }
209
+
210
+ // POSITIVE CONTROL: zero is the one count that must NOT be skipped, or the
211
+ // tool repairs nothing at all.
212
+ assert.ok(planOwnerMembership(org(), 0, owner(), ROLE).payload);
213
+ });
214
+
215
+ test("an unknown member count fails closed", () => {
216
+ for (const count of [-1, null, undefined, NaN, "0", 1.5]) {
217
+ const plan = planOwnerMembership(org(), count, owner(), ROLE);
218
+ assert.equal(plan.payload, undefined, `count ${String(count)} must plan nothing`);
219
+ }
220
+
221
+ // POSITIVE CONTROL: a real integer zero still plans.
222
+ assert.ok(planOwnerMembership(org(), 0, owner(), ROLE).payload);
223
+ });
224
+
225
+ test("refuses to guess an owner, and refuses to point at no role", () => {
226
+ // Nobody holds the organisation's e-mail address: there is no owner to write.
227
+ assert.equal(planOwnerMembership(org(), 0, null, ROLE).payload, undefined);
228
+ assert.equal(planOwnerMembership(org(), 0, owner({ _id: "" }), ROLE).payload, undefined);
229
+ assert.equal(planOwnerMembership(org(), 0, owner({ _id: "not-an-id" }), ROLE).payload, undefined);
230
+
231
+ // A membership pointing at no role is a member with no permissions at all.
232
+ assert.equal(planOwnerMembership(org(), 0, owner(), "").payload, undefined);
233
+ assert.equal(planOwnerMembership(org(), 0, owner(), "nope").payload, undefined);
234
+
235
+ // ... and an organisation with no usable id, exactly as `planForOrg` refuses.
236
+ assert.equal(planOwnerMembership({ _id: "" }, 0, owner(), ROLE).payload, undefined);
237
+
238
+ // POSITIVE CONTROL: all four present and well-formed does plan.
239
+ assert.ok(planOwnerMembership(org(), 0, owner(), ROLE).payload);
240
+ });
241
+
242
+ // ── the guard that decides whether an organisation is touched at all ────────
243
+ //
244
+ // Both counters fail closed on -1, so this parse is the whole safety rule. It
245
+ // used to fail OPEN: `Number("".split(" of ").pop())` is 0 and
246
+ // `Number.isInteger(0)` is true, so an absent or empty `pageRange` read as
247
+ // "this organisation has no owner" and it would have been written to.
248
+
249
+ test("an unreadable pageRange is -1, never a confident zero", () => {
250
+ for (const bad of ["", " ", undefined, null, "-- - -- of --", "of", "1-1 of", "1-1 of many", {}, []]) {
251
+ assert.equal(totalFromPageRange(bad), -1, `${JSON.stringify(bad)} must not read as a count`);
252
+ // ... and -1 must actually stop the write, not merely be returned.
253
+ assert.equal(planOwnerMembership(org(), totalFromPageRange(bad), owner(), ROLE).payload, undefined);
254
+ }
255
+
256
+ // POSITIVE CONTROL: the OLD parse called an empty pageRange zero owners. If
257
+ // this ever stops throwing, the fail-open is back.
258
+ assert.throws(() => assert.equal(Number("".split(" of ").pop()), -1));
259
+ });
260
+
261
+ // ── the dry run must not write, and "writes nothing" is a claim about a read ──
262
+ //
263
+ // `GET /api/roles` SELF-HEALS: `role.controller` seeds the two owner roles when
264
+ // a whole-org, unsearched, page-1 list comes back empty. A dry run that resolved
265
+ // the owner role therefore WROTE - 6 roles across 3 production organisations on
266
+ // 2026-09-09, from a run that printed "nothing was written". The role is
267
+ // resolved under `--apply` only, where the role pass creates it anyway.
268
+
269
+ const TOOL = readFileSync("tools/backfill-org-owner-roles/backfill.mjs", "utf8");
270
+ const SNIPPET = readFileSync(
271
+ "tools/backfill-org-owner-roles/repair-owner-membership-devtools-snippet.js",
272
+ "utf8",
273
+ );
274
+ /** The body of one function, up to the next top-level `}` at column 0. */
275
+ const body = (src, signature) => {
276
+ const start = src.indexOf(signature);
277
+ assert.ok(start >= 0, `${signature} not found`);
278
+ const end = src.indexOf("\n}", start);
279
+ return src.slice(start, end < 0 ? undefined : end);
280
+ };
281
+
282
+ test("the dry-run report never lists an organisation's roles", () => {
283
+ const report = body(TOOL, "async function reportMemberships(");
284
+ assert.doesNotMatch(report, /orgOwnerRoleId|api\/roles/);
285
+
286
+ // POSITIVE CONTROL: the apply path DOES resolve it - if this stops matching,
287
+ // the membership has nothing to point at.
288
+ assert.match(body(TOOL, "async function main("), /orgOwnerRoleId\(id\)/);
289
+ });
290
+
291
+ test("the snippet resolves the owner role only after APPLY", () => {
292
+ const [beforeApply, afterApply] = SNIPPET.split("if (!APPLY) {");
293
+ assert.equal(SNIPPET.includes("const APPLY = false;"), true, "APPLY must default to false");
294
+ assert.doesNotMatch(beforeApply, /call\(`\/api\/roles/);
295
+
296
+ // POSITIVE CONTROL: after the APPLY gate it must read the list, or the
297
+ // membership points at nothing.
298
+ assert.match(afterApply, /call\(`\/api\/roles\?org=\$\{p\.id\}&type=organization/);
299
+ });
300
+
301
+ test("no tool counts roles with a whole-org page-1 list, because that list WRITES", () => {
302
+ // role.controller :399-406 seeds the two owner roles when a whole-org,
303
+ // unsearched, site-less, page-1 list comes back empty -- the zero-role
304
+ // organisations these tools target. page=2 cannot heal, and the total in
305
+ // `pageRange` is countDocuments, so it is the same number either way.
306
+ const DRY = readFileSync("tools/backfill-org-owner-roles/dry-run-devtools-snippet.js", "utf8");
307
+
308
+ for (const [name, src] of [["backfill.mjs", TOOL], ["dry-run snippet", DRY]]) {
309
+ const reads = src.match(/\/api\/roles\?org=\$\{[a-zA-Z.]+\}&limit=1&page=[0-9]+/g) ?? [];
310
+ assert.ok(reads.length, `${name}: expected a role count read`);
311
+ for (const read of reads) {
312
+ assert.match(read, /page=2$/, `${name}: ${read} is a page-1 whole-org list, which seeds roles`);
313
+ }
314
+ }
315
+
316
+ // POSITIVE CONTROL: the shape that heals must be absent from both files. If
317
+ // this ever matches again, a "dry run" writes.
318
+ for (const src of [TOOL, DRY]) {
319
+ assert.doesNotMatch(src, /\/api\/roles\?org=\$\{[a-zA-Z.]+\}&limit=1&page=1/);
320
+ }
321
+ });
322
+
323
+ test("the apply pass says why it skipped an organisation it promised to repair", () => {
324
+ // The dry run resolves no role, so an organisation holding roles but none
325
+ // named `owner` prints as "to add" and must not then vanish in silence.
326
+ const main = body(TOOL, "async function main(");
327
+ assert.match(main, /if \(!plan\.payload\) \{/);
328
+ assert.match(main, /refused\.push\(\{ org, error: plan\.skip \}\)/);
329
+
330
+ // POSITIVE CONTROL: the bare `continue` that swallowed it must be gone.
331
+ assert.doesNotMatch(main, /if \(!plan\.payload\) continue;/);
332
+ });
333
+
334
+ test("a SUSPENDED owner membership counts, so no second one is written", () => {
335
+ // `createMemberDirect` does not de-duplicate, and a suspended owner row is a
336
+ // deliberate state - counting only `active` would write beside it.
337
+ const count = body(TOOL, "async function orgMemberCount(");
338
+ assert.match(count, /\["active", "suspended"\]/);
339
+ assert.match(count, /status=\$\{status\}/);
340
+ assert.match(count, /if \(count < 0\) return -1;/);
341
+
342
+ const snippetCount = body(SNIPPET, "for (const org of orgs.filter(");
343
+ assert.match(snippetCount, /\["active", "suspended"\]/);
344
+
345
+ // POSITIVE CONTROL: the OLD query pinned one status into the URL. If this
346
+ // ever matches again, the duplicate is back.
347
+ for (const src of [count, snippetCount]) {
348
+ assert.doesNotMatch(src, /status=active&limit=1/);
349
+ }
350
+ });
351
+
352
+ test("the header does not promise more than the tool does", () => {
353
+ // `POST /api/members/direct` also repoints the owner's `defaultOrg` and
354
+ // completes pending invitations, so "INSERT ONLY / never updates a user" was
355
+ // false. Both claims are the reason a lead would run this unattended.
356
+ for (const [name, src] of [["backfill.mjs", TOOL], ["repair snippet", SNIPPET]]) {
357
+ // One line, gutter stripped: these phrases wrap across comment lines, and a
358
+ // claim that moved to the next line is still the claim.
359
+ const header = src
360
+ .slice(0, src.indexOf("*/"))
361
+ .replace(/\n\s*\*\s?/g, " ")
362
+ .replace(/\s+/g, " ");
363
+ assert.match(header, /defaultOrg/, name);
364
+ assert.match(header, /completes any pending invitation/, name);
365
+ assert.match(header, /SELF-HEALS/, name);
366
+ assert.doesNotMatch(header, /INSERT ONLY/, name);
367
+ }
368
+ });
369
+
370
+ test("a genuinely empty page still reads as zero, so the repair still happens", () => {
371
+ // Measured against the live API: an empty result is "0-0 of 0", not "".
372
+ assert.equal(totalFromPageRange("0-0 of 0"), 0);
373
+ assert.ok(planOwnerMembership(org(), totalFromPageRange("0-0 of 0"), owner(), ROLE).payload);
374
+
375
+ // and a populated page is its total, not its page size
376
+ assert.equal(totalFromPageRange("1-1 of 2"), 2);
377
+ assert.equal(totalFromPageRange("1-10 of 146"), 146);
378
+ assert.match(planOwnerMembership(org(), totalFromPageRange("1-1 of 2"), owner(), ROLE).skip, /already has 2/);
379
+ });
@@ -211,12 +211,14 @@ function gateCalls() {
211
211
  return calls;
212
212
  }
213
213
 
214
- test("every console gate call site names a module - all 49 of them", () => {
214
+ test("every console gate call site names a module - all 50 of them", () => {
215
215
  const calls = gateCalls();
216
216
  // 48 since the module gate added `organization.controller.ts previewOrgModules`
217
217
  // and `member.controller.ts getModulesByUserIdType` (self-or-staff, `members`).
218
218
  // 49: `member.controller.ts getAllByUser` (self-or-staff, `members`).
219
- assert.equal(calls.length, 49, `gate call sites moved: ${calls.length}`);
219
+ // 50: `organization.controller.ts repairOwnerMembership` (staff, `organizations`)
220
+ // - the repair for an organisation whose owner membership was never written.
221
+ assert.equal(calls.length, 50, `gate call sites moved: ${calls.length}`);
220
222
 
221
223
  const ungoverned = calls.filter(
222
224
  ([, call]) =>
@@ -659,6 +659,7 @@ function buildApp(nsu, core) {
659
659
  getByEmail,
660
660
  getOrgsByEmail,
661
661
  update,
662
+ repairOwnerMembership,
662
663
  } = core.useOrgController();
663
664
 
664
665
  organizations.post("/", requireAuth, add);
@@ -671,6 +672,10 @@ function buildApp(nsu, core) {
671
672
  organizations.get("/email/:email", requireAuth, getByEmail);
672
673
  organizations.get("/orgs/:email", requireAuth, getOrgsByEmail);
673
674
  organizations.put("/:id", requireAuth, update);
675
+ // The owner-membership repair. API-core's own route is a separate change;
676
+ // this mount is the shape it will carry (`requireAuth`, and the staff gate
677
+ // inside the handler).
678
+ organizations.post("/:id/owner-membership", requireAuth, repairOwnerMembership);
674
679
  }
675
680
 
676
681
  // mirrors iservice365-API-core/src/routes/promo-code.route.ts