@7365admin1/core 3.56.0 → 3.58.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.56.0",
4
+ "version": "3.58.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/invite-role-scope.util.ts src/models/personal-emergency-chain.model.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/invite-role-scope.util.ts src/models/personal-emergency-chain.model.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
  },
@@ -651,6 +651,7 @@ function buildApp(nsu, core) {
651
651
  {
652
652
  const {
653
653
  getAll,
654
+ add,
654
655
  addOnboardingOrg,
655
656
  getOrgsByUserId,
656
657
  getByName,
@@ -660,6 +661,7 @@ function buildApp(nsu, core) {
660
661
  update,
661
662
  } = core.useOrgController();
662
663
 
664
+ organizations.post("/", requireAuth, add);
663
665
  organizations.get("/", requireAuth, getAll);
664
666
  organizations.post("/onboarding", requireAuth, addOnboardingOrg);
665
667
  organizations.get("/user/:user", requireAuth, getOrgsByUserId);
@@ -0,0 +1,320 @@
1
+ /**
2
+ * A NEW client organisation gets its own owner roles, and NO existing
3
+ * organisation is touched.
4
+ *
5
+ * ## What this is proving
6
+ *
7
+ * Measured on staging 2026-09-07: **74 of 195 live organisations hold not one
8
+ * role**, and not one of those 74 has a subscription. The paid signup path
9
+ * (`subscription.service.ts:145-165`) has always seeded two `owner` roles with
10
+ * `permissions: ["*"]`; the two paths that actually create clients today —
11
+ * `POST /organizations` from the Seven365 console and `POST
12
+ * /organizations/onboarding` from the client wizard — seeded nothing. An
13
+ * organisation with no role of its own has nothing to invite anybody with,
14
+ * which is why the console offers the ORG-LESS templates instead: the shared
15
+ * documents that one console save took every client's modules away with.
16
+ *
17
+ * The unit half (`test/org-default-roles.test.mjs`) proves the shape. This
18
+ * proves the rows land, over real HTTP, through the real controller, and — the
19
+ * assertion that matters most on the day after an outage — that an
20
+ * organisation that already existed comes out of the run byte-identical.
21
+ *
22
+ * Everything here is created and thrown away by the harness: an in-process
23
+ * MongoDB replica set, a loopback Redis, a loopback mail sink. No staging or
24
+ * production database, Redis, mailbox or endpoint is touched.
25
+ *
26
+ * Run with: yarn test:e2e
27
+ */
28
+
29
+ import { after, before, describe, it } from "node:test";
30
+ import assert from "node:assert/strict";
31
+ import { ObjectId } from "mongodb";
32
+
33
+ import { startHarness } from "./harness.mjs";
34
+
35
+ const PASSWORD = "OrgDefaultRoles-Passw0rd!";
36
+ const STAFF = "odr-staff@e2e.example.com"; // Seven365 console, creates clients
37
+ const WIZARD = "odr-wizard@e2e.example.com"; // a client walking the wizard
38
+
39
+ const NATURE = "security_agency";
40
+
41
+ describe("a new organisation is seeded with its own owner roles", { concurrency: 1 }, () => {
42
+ let h;
43
+ let id;
44
+ let staffSid;
45
+ let wizardSid;
46
+ const result = [];
47
+
48
+ const record = (name, fn) =>
49
+ it(name, async () => {
50
+ try {
51
+ await fn();
52
+ result.push(["PASS", name]);
53
+ } catch (error) {
54
+ result.push(["FAIL", name]);
55
+ throw error;
56
+ }
57
+ });
58
+
59
+ before(async () => {
60
+ h = await startHarness();
61
+ id = await seed(h);
62
+ staffSid = await h.login(STAFF, PASSWORD);
63
+ wizardSid = await h.login(WIZARD, PASSWORD);
64
+ }, { timeout: 300000 });
65
+
66
+ after(async () => {
67
+ if (h) await h.stop();
68
+ console.log("\n--- org default roles: per-case result ---");
69
+ for (const [verdict, name] of result) console.log(`${verdict} ${name}`);
70
+ });
71
+
72
+ const rolesOf = (org) =>
73
+ h.db.collection("roles").find({ org: new ObjectId(org.toString()) }).toArray();
74
+
75
+ // ---- the console path ---------------------------------------------------
76
+
77
+ record("1. POST /organizations still answers 201 — client creation is not broken", async () => {
78
+ const res = await h.api("/organizations/", {
79
+ method: "POST",
80
+ sid: staffSid,
81
+ body: {
82
+ name: "ODR Console Client",
83
+ type: "org",
84
+ nature: NATURE,
85
+ email: "odr-console@e2e.example.com",
86
+ },
87
+ });
88
+
89
+ assert.equal(res.status, 201, JSON.stringify(res.body));
90
+ });
91
+
92
+ record("2. that organisation now holds the two owner roles a paid signup gets", async () => {
93
+ const org = await h.db
94
+ .collection("organizations")
95
+ .findOne({ name: "ODR Console Client" });
96
+ assert.ok(org, "the organisation was created");
97
+
98
+ const roles = await rolesOf(org._id);
99
+
100
+ assert.equal(roles.length, 2, `expected two roles, got ${JSON.stringify(roles)}`);
101
+ assert.deepEqual(
102
+ roles.map((r) => r.type).sort(),
103
+ ["organization", NATURE],
104
+ "one organisation role and one typed by the organisation's nature",
105
+ );
106
+
107
+ for (const role of roles) {
108
+ assert.equal(role.name, "owner");
109
+ assert.deepEqual(role.permissions, ["*"], '["*"] means everything — nobody is locked out');
110
+ assert.equal(role.default, true);
111
+ assert.equal(role.status, "active");
112
+ }
113
+ });
114
+
115
+ record("3. every seeded role carries THIS organisation's id and no other", async () => {
116
+ // The 2026-09-07 outage was an `org`-less role of `type: "organization"` —
117
+ // one document shared by every client invited with it. If seeding could
118
+ // ever emit one, this fails.
119
+ const org = await h.db
120
+ .collection("organizations")
121
+ .findOne({ name: "ODR Console Client" });
122
+
123
+ for (const role of await rolesOf(org._id)) {
124
+ assert.ok(role.org, "the role has an org");
125
+ assert.equal(role.org.toString(), org._id.toString());
126
+ }
127
+
128
+ const orgless = await h.db
129
+ .collection("roles")
130
+ .find({ $or: [{ org: "" }, { org: null }, { org: { $exists: false } }] })
131
+ .toArray();
132
+
133
+ assert.equal(
134
+ orgless.filter((r) => r.name === "owner").length,
135
+ 0,
136
+ "seeding created no org-less shared template",
137
+ );
138
+ });
139
+
140
+ // ---- the wizard path ----------------------------------------------------
141
+
142
+ record("4. POST /organizations/onboarding seeds the same two roles", async () => {
143
+ const res = await h.api("/organizations/onboarding", {
144
+ method: "POST",
145
+ sid: wizardSid,
146
+ body: {
147
+ name: "ODR Wizard Client",
148
+ type: "org",
149
+ nature: "cleaning_services",
150
+ email: "odr-wizard-org@e2e.example.com",
151
+ },
152
+ });
153
+
154
+ assert.equal(res.status, 201, JSON.stringify(res.body));
155
+
156
+ const org = await h.db
157
+ .collection("organizations")
158
+ .findOne({ name: "ODR Wizard Client" });
159
+ assert.ok(org, "the organisation was created");
160
+
161
+ const roles = await rolesOf(org._id);
162
+ assert.deepEqual(
163
+ roles.map((r) => r.type).sort(),
164
+ ["cleaning_services", "organization"],
165
+ );
166
+ for (const role of roles) {
167
+ assert.equal(role.name, "owner");
168
+ assert.deepEqual(role.permissions, ["*"]);
169
+ assert.equal(role.org.toString(), org._id.toString());
170
+ }
171
+ });
172
+
173
+ record("5. the wizard still gets its organisation back in the response body", async () => {
174
+ // The wizard reads `data._id` from this response to drive step 2. Seeding
175
+ // runs between the insert and that read, so a seeding failure must not be
176
+ // able to blank it.
177
+ const res = await h.api("/organizations/onboarding", {
178
+ method: "POST",
179
+ sid: wizardSid,
180
+ body: {
181
+ name: "ODR Wizard Client Two",
182
+ type: "org",
183
+ nature: "landscaping_services",
184
+ email: "odr-wizard-two@e2e.example.com",
185
+ },
186
+ });
187
+
188
+ assert.equal(res.status, 201, JSON.stringify(res.body));
189
+ assert.ok(res.body?.data?._id, `no organisation in the body: ${JSON.stringify(res.body)}`);
190
+ });
191
+
192
+ // ---- the part that matters most ----------------------------------------
193
+
194
+ record("6. the EXISTING organisation and its roles are byte-identical", async () => {
195
+ const org = await h.db.collection("organizations").findOne({ _id: id.existingOrg });
196
+ assert.deepEqual(
197
+ org,
198
+ id.existingOrgBefore,
199
+ "an organisation that already existed was modified",
200
+ );
201
+
202
+ const roles = await h.db
203
+ .collection("roles")
204
+ .find({ org: id.existingOrg })
205
+ .sort({ _id: 1 })
206
+ .toArray();
207
+
208
+ assert.deepEqual(
209
+ roles,
210
+ id.existingRolesBefore,
211
+ "an existing organisation's roles were modified",
212
+ );
213
+ });
214
+
215
+ record("7. no existing MEMBER was repointed and no role was deleted", async () => {
216
+ const members = await h.db
217
+ .collection("members")
218
+ .find({})
219
+ .sort({ _id: 1 })
220
+ .toArray();
221
+
222
+ assert.deepEqual(members, id.membersBefore, "a members row changed");
223
+
224
+ for (const roleId of id.allRoleIdsBefore) {
225
+ const still = await h.db.collection("roles").findOne({ _id: roleId });
226
+ assert.ok(still, `role ${roleId} disappeared`);
227
+ }
228
+ });
229
+
230
+ record("8. positive control: the run really did create roles", async () => {
231
+ // If cases 6 and 7 passed because nothing happened at all, they prove
232
+ // nothing. This is the count that makes them mean something.
233
+ const after = await h.db.collection("roles").countDocuments();
234
+
235
+ assert.ok(
236
+ after > id.roleCountBefore,
237
+ `roles went from ${id.roleCountBefore} to ${after} — seeding wrote nothing`,
238
+ );
239
+ assert.equal(
240
+ after - id.roleCountBefore,
241
+ 6,
242
+ "three organisations were created, two roles each",
243
+ );
244
+ });
245
+ });
246
+
247
+ async function seed(h) {
248
+ const now = new Date().toISOString();
249
+ const hashed = await h.hashPassword(PASSWORD);
250
+
251
+ const staffRole = new ObjectId();
252
+ const existingOrg = new ObjectId();
253
+ const existingRole = new ObjectId();
254
+ const wizardRole = new ObjectId();
255
+
256
+ await h.db.collection("organizations").insertOne({
257
+ _id: existingOrg,
258
+ name: "ODR Existing Client",
259
+ email: "odr-existing@e2e.example.com",
260
+ type: "org",
261
+ nature: NATURE,
262
+ status: "active",
263
+ createdAt: now,
264
+ });
265
+
266
+ await h.db.collection("roles").insertMany([
267
+ // Seven365 staff: a members row of type "admin" whose role is ALSO type
268
+ // "admin". `["*"]` so the console gate passes and case 1 measures seeding,
269
+ // not authorization.
270
+ { _id: staffRole, name: "Staff Wildcard", type: "admin", status: "active", permissions: ["*"] },
271
+ // The existing client's own role, the one case 6 proves untouched.
272
+ {
273
+ _id: existingRole,
274
+ name: "Existing Org Admin",
275
+ org: existingOrg,
276
+ type: "organization",
277
+ status: "active",
278
+ permissions: ["members:view-member"],
279
+ },
280
+ { _id: wizardRole, name: "Wizard Owner", org: existingOrg, type: NATURE, status: "active", permissions: ["*"] },
281
+ ]);
282
+
283
+ const users = await h.db.collection("users").insertMany([
284
+ { email: STAFF, password: hashed, name: "ODR Staff", status: "active", createdAt: now },
285
+ {
286
+ email: WIZARD,
287
+ password: hashed,
288
+ name: "ODR Wizard User",
289
+ status: "active",
290
+ defaultOrg: existingOrg.toString(),
291
+ createdAt: now,
292
+ },
293
+ ]);
294
+
295
+ await h.db.collection("members").insertMany([
296
+ { user: users.insertedIds[0], type: "admin", role: staffRole, status: "active" },
297
+ {
298
+ user: users.insertedIds[1],
299
+ org: existingOrg,
300
+ type: "organization",
301
+ role: existingRole,
302
+ status: "active",
303
+ },
304
+ ]);
305
+
306
+ return {
307
+ existingOrg,
308
+ existingOrgBefore: await h.db.collection("organizations").findOne({ _id: existingOrg }),
309
+ existingRolesBefore: await h.db
310
+ .collection("roles")
311
+ .find({ org: existingOrg })
312
+ .sort({ _id: 1 })
313
+ .toArray(),
314
+ membersBefore: await h.db.collection("members").find({}).sort({ _id: 1 }).toArray(),
315
+ allRoleIdsBefore: (await h.db.collection("roles").find({}, { projection: { _id: 1 } }).toArray()).map(
316
+ (r) => r._id,
317
+ ),
318
+ roleCountBefore: await h.db.collection("roles").countDocuments(),
319
+ };
320
+ }