@7365admin1/core 3.53.9 → 3.55.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.53.9",
4
+ "version": "3.55.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/invite-role-scope.util.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/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
  },
@@ -706,6 +706,14 @@ function buildApp(nsu, core) {
706
706
  notificationPreferences.put("/", requireAuth, update);
707
707
  }
708
708
 
709
+ // mirrors iservice365-API-core/src/routes/personal-emergency-chain.route.ts
710
+ const personalEmergencyContacts = express.Router();
711
+ {
712
+ const { get, update } = core.usePersonalEmergencyChainController();
713
+ personalEmergencyContacts.get("/:userId", requireAuth, get);
714
+ personalEmergencyContacts.put("/:userId", requireAuth, update);
715
+ }
716
+
709
717
 
710
718
  // mirrors iservice365-API-core/src/routes/vehicle.route.ts
711
719
  const vehicles = express.Router();
@@ -1527,6 +1535,7 @@ function buildApp(nsu, core) {
1527
1535
  app.use("/api/bulletin-videos", bulletinVideos);
1528
1536
  app.use("/api/notifications", notifications);
1529
1537
  app.use("/api/notification-preferences", notificationPreferences);
1538
+ app.use("/api/personal-emergency-contacts", personalEmergencyContacts);
1530
1539
  app.use("/api/subscriptions", subscriptions);
1531
1540
  app.use("/api/invoices", invoices);
1532
1541
  app.use("/api/post-preloved", postPreloved);
@@ -0,0 +1,420 @@
1
+ // The whole Notification Settings matrix, measured rather than reasoned about.
2
+ //
3
+ // The owner's complaint is not "is the guard called" — it is "does moving a
4
+ // switch change what I actually receive". So every category this package can
5
+ // send is driven through its REAL sender, against a real MongoDB, with the
6
+ // preference written and re-read over REAL HTTP through the real `requireAuth`,
7
+ // and the delivery counted on both sides: a row in the `notifications`
8
+ // collection, and a payload handed to the push sender.
9
+ //
10
+ // Four measurements per category, and the FIRST one is a positive control:
11
+ //
12
+ // ON both channels must deliver — a zero here means the test is broken,
13
+ // not that the feature works
14
+ // inApp off -> nothing in the app, the phone still buzzes
15
+ // push off -> the phone is quiet, the message is still in the app
16
+ // both off -> nothing at all
17
+ //
18
+ // then the switches go back on and delivery must return. Every write is made
19
+ // with `PUT /notification-preferences` and every read with a fresh
20
+ // `GET /notification-preferences`, so "it saved" and "it is obeyed" are two
21
+ // separate proofs rather than one.
22
+ //
23
+ // The six categories whose senders live in `iservice365-API-core` are measured
24
+ // the same way, in that repository's own `test/e2e/notification-matrix`.
25
+ //
26
+ // Run with: yarn test:e2e
27
+
28
+ import { after, before, describe, it } from "node:test";
29
+ import assert from "node:assert/strict";
30
+ import { ObjectId } from "mongodb";
31
+
32
+ import { startHarness } from "./harness.mjs";
33
+
34
+ const PASSWORD = "Matrix-Passw0rd!";
35
+ const STAFF = "matrix-staff@e2e.example.com";
36
+
37
+ const RESIDENT_SLUG = "iservice365-resident-mobile-app";
38
+ const MA_SLUG = "iservice365-ma-mobile-app";
39
+
40
+ /** every core sender, one per category it governs */
41
+ function senders(h, id) {
42
+ const to = () => id.user.toString();
43
+ const site = () => id.site;
44
+
45
+ return [
46
+ {
47
+ key: "bulletinBoard",
48
+ label: "Notice Board",
49
+ trigger: () =>
50
+ h.core.NotificationService.bulletinBoardCreated({
51
+ to: to(),
52
+ bulletinId: new ObjectId(),
53
+ subject: "Lift maintenance",
54
+ status: "published",
55
+ siteId: site(),
56
+ }),
57
+ },
58
+ {
59
+ key: "event",
60
+ label: "Events",
61
+ trigger: () =>
62
+ h.core.NotificationService.eventCreatedForMA({
63
+ to: to(),
64
+ eventId: new ObjectId(),
65
+ status: "published",
66
+ siteId: site(),
67
+ }),
68
+ },
69
+ {
70
+ key: "visitors",
71
+ label: "Visitors",
72
+ trigger: () =>
73
+ h.core.NotificationService.invitedVisitorCheckOutForMa({
74
+ to: to(),
75
+ name: "Sam Visitor",
76
+ _id: new ObjectId(),
77
+ siteId: site(),
78
+ }),
79
+ },
80
+ {
81
+ key: "onlineForm",
82
+ label: "Online Forms",
83
+ trigger: () =>
84
+ h.core.NotificationService.onlineFormRequestStatusUpdated({
85
+ to: to(),
86
+ onlineFormId: new ObjectId(),
87
+ status: "approved",
88
+ siteId: site(),
89
+ }),
90
+ },
91
+ {
92
+ key: "prelovedMarketplace",
93
+ label: "Marketplace",
94
+ trigger: () =>
95
+ h.core.NotificationService.prelovedMarketplacePostCreation({
96
+ to: to(),
97
+ prelovedId: new ObjectId(),
98
+ subject: "Bookshelf",
99
+ status: "published",
100
+ siteId: site(),
101
+ }),
102
+ },
103
+ {
104
+ key: "serviceProviderInvite",
105
+ label: "Service Provider Invitations",
106
+ trigger: () =>
107
+ h.core.NotificationService.serviceProviderInvited({
108
+ to: to(),
109
+ invitationId: new ObjectId(),
110
+ title: "You have been invited",
111
+ orgName: "Northwind",
112
+ siteName: "Aurora Residences",
113
+ siteId: site(),
114
+ }),
115
+ },
116
+ {
117
+ key: "incidentReport",
118
+ label: "Incident Reports",
119
+ trigger: () =>
120
+ h.core.NotificationService.incidentReported({
121
+ siteId: site(),
122
+ incidentId: new ObjectId(),
123
+ reportId: "IR-1",
124
+ siteName: "Aurora Residences",
125
+ }),
126
+ },
127
+ {
128
+ key: "emergencyContact",
129
+ label: "Emergency Contacts",
130
+ trigger: () =>
131
+ h.core.NotificationService.emergencyContactChanged({
132
+ siteId: site(),
133
+ contactId: new ObjectId(),
134
+ contactTitle: "Fire station",
135
+ action: "updated",
136
+ }),
137
+ },
138
+ {
139
+ key: "virtualPatrol",
140
+ label: "Guard Patrols",
141
+ trigger: () =>
142
+ h.core.NotificationService.patrolException({
143
+ siteId: site(),
144
+ patrolLogId: new ObjectId(),
145
+ routeName: "Basement",
146
+ problem: "A patrol finished late",
147
+ }),
148
+ },
149
+ {
150
+ key: "cameraFault",
151
+ label: "Camera Faults",
152
+ trigger: () =>
153
+ h.core.NotificationService.cameraFault({
154
+ siteId: site(),
155
+ cameraId: new ObjectId(),
156
+ message: "Camera 3 is not answering.",
157
+ }),
158
+ },
159
+ ];
160
+ }
161
+
162
+ describe("notification settings: the measured matrix", { concurrency: 1 }, () => {
163
+ let h;
164
+ let sid;
165
+ const id = {};
166
+ const matrix = [];
167
+
168
+ before(async () => {
169
+ h = await startHarness();
170
+ Object.assign(id, await seed(h));
171
+ sid = await h.login(STAFF, PASSWORD);
172
+ }, { timeout: 300000 });
173
+
174
+ after(async () => {
175
+ if (h) await h.stop();
176
+ console.log("\n--- observed matrix: core senders ---");
177
+ console.log(
178
+ "category".padEnd(26) +
179
+ "ON inApp/push".padEnd(16) +
180
+ "inApp OFF".padEnd(12) +
181
+ "push OFF".padEnd(12) +
182
+ "both OFF".padEnd(12) +
183
+ "restored".padEnd(12) +
184
+ "email",
185
+ );
186
+ for (const row of matrix) console.log(row);
187
+ });
188
+
189
+ const getPrefs = () => h.api("/notification-preferences", { sid });
190
+ const putPrefs = (off) =>
191
+ h.api("/notification-preferences", { method: "PUT", sid, body: { off } });
192
+
193
+ /** run one sender and report what actually landed */
194
+ async function measure(trigger) {
195
+ const where = { userId: new ObjectId(id.user.toString()) };
196
+ const before = await h.db.collection("notifications").countDocuments(where);
197
+ const pushBefore = h.pushes.length;
198
+
199
+ await trigger();
200
+
201
+ return {
202
+ inApp: (await h.db.collection("notifications").countDocuments(where)) - before,
203
+ push: h.pushes.length - pushBefore,
204
+ };
205
+ }
206
+
207
+ /** what a FRESH request says the screen shows for this category */
208
+ async function screenFor(key) {
209
+ const res = await getPrefs();
210
+ assert.equal(res.status, 200, JSON.stringify(res.body));
211
+ const category = res.body.data.categories.find((c) => c.key === key);
212
+ assert.ok(category, `${key} is not on this person's screen`);
213
+ return Object.fromEntries(
214
+ category.channels.map((c) => [c.key, { enabled: c.enabled, supported: c.supported }]),
215
+ );
216
+ }
217
+
218
+ it("every rendered category is on this one person's screen", async () => {
219
+ const shown = (await getPrefs()).body.data.categories.map((c) => c.key);
220
+ const rendered = h.core.NOTIFICATION_CATEGORIES.filter((c) => c.rendered).map((c) => c.key);
221
+ assert.deepEqual(
222
+ rendered.filter((k) => !shown.includes(k)),
223
+ [],
224
+ "the seeded person must see every switch, or the matrix has holes",
225
+ );
226
+ });
227
+
228
+ for (const sender of senders(null, null).map((s) => s.key)) {
229
+ it(`${sender}: a switch turned off actually stops it`, async () => {
230
+ const spec = senders(h, id).find((s) => s.key === sender);
231
+
232
+ // ---------------------------------------------------------------- ON
233
+ await putPrefs([]);
234
+ const screenOn = await screenFor(spec.key);
235
+ assert.equal(screenOn.inApp.enabled, true, "in-app reads as on");
236
+ assert.equal(screenOn.push.enabled, true, "phone alerts read as on");
237
+
238
+ const on = await measure(spec.trigger);
239
+ assert.ok(on.inApp > 0, `POSITIVE CONTROL FAILED: ${spec.key} delivered nothing in the app while switched ON — the test is wrong, not the feature`);
240
+ assert.ok(on.push > 0, `POSITIVE CONTROL FAILED: ${spec.key} sent no push while switched ON — the test is wrong, not the feature`);
241
+
242
+ // ------------------------------------------------------ in-app OFF
243
+ await putPrefs([{ category: spec.key, channel: "inApp" }]);
244
+ const savedInApp = await screenFor(spec.key);
245
+ assert.equal(savedInApp.inApp.enabled, false, "the OFF choice survived a fresh read");
246
+ assert.equal(savedInApp.push.enabled, true, "and it did not move the other switch");
247
+
248
+ const inAppOff = await measure(spec.trigger);
249
+ assert.equal(inAppOff.inApp, 0, `${spec.key} still wrote to the app feed with in-app switched off`);
250
+ assert.ok(inAppOff.push > 0, `${spec.key} lost its push when only in-app was switched off`);
251
+
252
+ // -------------------------------------------------------- push OFF
253
+ await putPrefs([{ category: spec.key, channel: "push" }]);
254
+ const savedPush = await screenFor(spec.key);
255
+ assert.equal(savedPush.push.enabled, false);
256
+ assert.equal(savedPush.inApp.enabled, true);
257
+
258
+ const pushOff = await measure(spec.trigger);
259
+ assert.equal(pushOff.push, 0, `${spec.key} still sent a push with phone alerts switched off`);
260
+ assert.ok(pushOff.inApp > 0, `${spec.key} vanished from the app feed when only the phone was switched off`);
261
+
262
+ // -------------------------------------------------------- both OFF
263
+ await putPrefs([
264
+ { category: spec.key, channel: "inApp" },
265
+ { category: spec.key, channel: "push" },
266
+ ]);
267
+ const bothOff = await measure(spec.trigger);
268
+ assert.deepEqual(bothOff, { inApp: 0, push: 0 }, `${spec.key} still delivered with everything switched off`);
269
+
270
+ // -------------------------------------------------------- restored
271
+ await putPrefs([]);
272
+ const restoredScreen = await screenFor(spec.key);
273
+ assert.equal(restoredScreen.inApp.enabled, true);
274
+ assert.equal(restoredScreen.push.enabled, true);
275
+ const restored = await measure(spec.trigger);
276
+ assert.ok(restored.inApp > 0 && restored.push > 0, `${spec.key} did not come back when switched on again`);
277
+
278
+ // ----------------------------------------------------------- email
279
+ // An absent key IS the answer now: the server sends only the channels a
280
+ // category can really deliver on, so email is missing rather than
281
+ // present-and-dimmed.
282
+ const email = restoredScreen.email;
283
+ assert.equal(email, undefined, `${spec.key} still offers an email switch nothing reads`);
284
+
285
+ matrix.push(
286
+ spec.key.padEnd(26) +
287
+ `${on.inApp}/${on.push}`.padEnd(16) +
288
+ `${inAppOff.inApp}/${inAppOff.push}`.padEnd(12) +
289
+ `${pushOff.inApp}/${pushOff.push}`.padEnd(12) +
290
+ `${bothOff.inApp}/${bothOff.push}`.padEnd(12) +
291
+ `${restored.inApp}/${restored.push}`.padEnd(12) +
292
+ (email ? "OFFERED (bug)" : "not offered"),
293
+ );
294
+ });
295
+ }
296
+
297
+ it("no category in the catalogue can deliver email", async () => {
298
+ const withEmail = h.core.NOTIFICATION_CATEGORIES.filter((c) => c.channels.email).map((c) => c.key);
299
+ assert.deepEqual(
300
+ withEmail,
301
+ [],
302
+ "a category claims an email channel — then a sender must call filterRecipients with \"email\", and none does",
303
+ );
304
+ });
305
+
306
+ it("a category that cannot use a channel keeps nobody on it", async () => {
307
+ const { filterRecipients } = h.core.useNotificationPreferenceRepo();
308
+ assert.deepEqual(
309
+ await filterRecipients([id.user.toString()], "bulletinBoard", "email"),
310
+ [],
311
+ "an email send would reach nobody, which is why no switch is offered",
312
+ );
313
+ });
314
+ });
315
+
316
+ async function seed(h) {
317
+ const now = new Date().toISOString();
318
+
319
+ const org = new ObjectId();
320
+ const site = new ObjectId();
321
+ const role = new ObjectId();
322
+
323
+ await h.db.collection("organizations").insertOne({
324
+ _id: org,
325
+ name: "Matrix Property Management",
326
+ email: "matrix-org@e2e.example.com",
327
+ type: "property-management",
328
+ status: "active",
329
+ // so the vendor-only switch is on this one screen too
330
+ isMarketplaceVendor: true,
331
+ createdAt: now,
332
+ });
333
+
334
+ await h.db.collection("sites").insertOne({
335
+ _id: site,
336
+ name: "Aurora Residences",
337
+ org: org.toString(),
338
+ status: "active",
339
+ createdAt: now,
340
+ });
341
+
342
+ // One role holding every module behind a rendered category, so a single
343
+ // person is offered the whole screen and no category is measured against a
344
+ // different account.
345
+ await h.db.collection("roles").insertOne({
346
+ _id: role,
347
+ name: "Everything",
348
+ org,
349
+ status: "active",
350
+ permissions: [
351
+ "feedbacks:see-all-feedback",
352
+ "workOrder:see-all-work-orders",
353
+ "bulletin-board:see-all",
354
+ "event-mgmt:see-all",
355
+ "facility-booking-mgmt:see-all",
356
+ "visitorManagement:see-all",
357
+ "online-form-configuration:see-all",
358
+ "service-provider-mgmt:see-all",
359
+ "incident-reports:see-all",
360
+ "emergency-contact:see-all",
361
+ "virtual-patrol:see-all",
362
+ "site-settings:see-all",
363
+ ],
364
+ });
365
+
366
+ const user = (
367
+ await h.db.collection("users").insertOne({
368
+ email: STAFF,
369
+ password: await h.hashPassword(PASSWORD),
370
+ name: "Max Matrix",
371
+ status: "active",
372
+ defaultOrg: org.toString(),
373
+ createdAt: now,
374
+ })
375
+ ).insertedId;
376
+
377
+ // `type: "resident"` as well as the role, so the resident-default categories
378
+ // are on the same screen. The safety senders resolve their own recipients
379
+ // from `siteId` + the role's permissions, so the membership must name the
380
+ // site or four categories would measure zero for the wrong reason.
381
+ await h.db.collection("members").insertOne({
382
+ user,
383
+ org,
384
+ siteId: site,
385
+ role,
386
+ type: "resident",
387
+ status: "active",
388
+ });
389
+
390
+ // A device per app slug the senders address, plus one with no slug at all
391
+ // (the service-provider invite sends without one). A missing token would read
392
+ // exactly like a suppressed push.
393
+ await h.db.collection("push_tokens").insertMany([
394
+ {
395
+ userId: user.toString(),
396
+ token: "ExponentPushToken[matrix-resident]",
397
+ platform: "ios",
398
+ appSlug: RESIDENT_SLUG,
399
+ createdAt: new Date(),
400
+ updatedAt: new Date(),
401
+ },
402
+ {
403
+ userId: user.toString(),
404
+ token: "ExponentPushToken[matrix-ma]",
405
+ platform: "android",
406
+ appSlug: MA_SLUG,
407
+ createdAt: new Date(),
408
+ updatedAt: new Date(),
409
+ },
410
+ {
411
+ userId: user.toString(),
412
+ token: "ExponentPushToken[matrix-noslug]",
413
+ platform: "ios",
414
+ createdAt: new Date(),
415
+ updatedAt: new Date(),
416
+ },
417
+ ]);
418
+
419
+ return { org, site, role, user };
420
+ }
@@ -297,18 +297,31 @@ describe("notification preferences, end to end", { concurrency: 1 }, () => {
297
297
  );
298
298
 
299
299
  record(
300
- "12. a channel that cannot deliver is shown, dimmed, with a reason — and never on",
300
+ "12. a channel that cannot deliver is not offered at all",
301
301
  async () => {
302
+ // It used to be drawn, dimmed, and apologised for. A control that cannot
303
+ // move is not a setting: it tells a person they have been asked
304
+ // something they have not, and it made the summary line claim "on
305
+ // everywhere" for a category with no email at all.
302
306
  const res = await getPrefs(managerSid);
303
307
  const workOrder = res.body.data.categories.find(
304
308
  (c) => c.key === "workOrder",
305
309
  );
306
- const email = workOrder.channels.find((c) => c.key === "email");
307
310
 
308
- assert.equal(email.supported, false);
309
- assert.equal(email.enabled, false);
310
- assert.ok(email.note && email.note.length > 20, "a plain reason is given");
311
- assert.doesNotMatch(email.note, /_|[a-z][A-Z]/, "no internal names in the reason");
311
+ assert.deepEqual(
312
+ workOrder.channels.map((c) => c.key),
313
+ ["inApp", "push"],
314
+ "only the channels this category can really deliver on are sent",
315
+ );
316
+ assert.ok(
317
+ workOrder.channels.every((c) => c.supported),
318
+ "everything sent is switchable",
319
+ );
320
+ assert.equal(
321
+ res.body.data.channels.find((c) => c.key === "email"),
322
+ undefined,
323
+ "and the channel dictionary does not advertise one either",
324
+ );
312
325
  },
313
326
  );
314
327
 
@@ -502,8 +515,8 @@ describe("notification preferences, end to end", { concurrency: 1 }, () => {
502
515
 
503
516
  assert.deepEqual(
504
517
  emailCapable,
505
- ["serviceProviderInvite"],
506
- "only one category can govern email at all",
518
+ [],
519
+ "no category governs email at all — nothing in the estate sends a notification by email",
507
520
  );
508
521
 
509
522
  // and the catalogue holds no category for a one-time code, a password