@7365admin1/core 3.54.0 → 3.56.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.54.0",
4
+ "version": "3.56.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 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/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
  },
@@ -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
@@ -97,9 +97,11 @@ describe("onboarding step 1 says what is wrong when the save is refused", { conc
97
97
  ownerSid = await h.login(OWNER, PASSWORD);
98
98
 
99
99
  assert.equal(
100
- await h.db.collection("members").countDocuments({ user: id.owner }),
100
+ await h.db
101
+ .collection("members")
102
+ .countDocuments({ user: id.owner, org: id.ownOrg }),
101
103
  0,
102
- "the owner must hold no membership — reach is by `createdBy` (DV-0248)",
104
+ "the owner must hold no membership of THIS organisation — reach is by `createdBy` (DV-0248)",
103
105
  );
104
106
  assert.equal((await ownOrg()).name, "Ben Org");
105
107
  });
@@ -160,6 +162,49 @@ describe("onboarding step 1 says what is wrong when the save is refused", { conc
160
162
  assert.equal(saved.contact, "+63 9569346664");
161
163
  });
162
164
 
165
+ // ---- the second control: the legacy document shape is NOT the cause -------
166
+
167
+ record("5. control — the legacy production document shape saves unchanged", async () => {
168
+ // The 16 rows a lead read out of the production `organizations` collection
169
+ // carry the LEGACY `organization-api` field set — `contactNumber`, `sites`,
170
+ // `logo`, `createdBySpId` — and `createdAt`, `updatedAt` and `createdBy`
171
+ // are EMPTY STRINGS, not dates and not ObjectIds. The obvious suspicion is
172
+ // that saving over a row like that throws on a type: `createdAt: ""` where
173
+ // a Date is expected, `createdBy: ""` where an ObjectId is.
174
+ //
175
+ // It cannot. This repository talks to the raw `mongodb` driver — there is
176
+ // no Mongoose, no model layer and no cast step between `update()` and the
177
+ // server. `$set` names `name`, `email`, `contact` and `updatedAt` and
178
+ // nothing else, and MongoDB replaces a field wholesale whatever BSON type
179
+ // it held before. The legacy fields are never read, never re-validated and
180
+ // never rewritten.
181
+ //
182
+ // This case is the proof: same modal body, same route, a row in exactly
183
+ // that shape, no unique index on it — 200, and the legacy fields survive
184
+ // untouched.
185
+ const res = await h.api(`/organizations/${id.legacyOrg}`, {
186
+ method: "PUT",
187
+ sid: ownerSid,
188
+ body: modalBody("Legacy Shaped Org Renamed"),
189
+ });
190
+
191
+ assert.equal(res.status, 200, JSON.stringify(res.body));
192
+
193
+ const saved = await h.db
194
+ .collection("organizations")
195
+ .findOne({ _id: id.legacyOrg });
196
+
197
+ assert.equal(saved.name, "Legacy Shaped Org Renamed");
198
+ assert.equal(saved.contact, "+63 9569346664");
199
+ assert.ok(saved.updatedAt instanceof Date, "updatedAt is replaced by a Date");
200
+ // untouched, still exactly as the legacy service left them
201
+ assert.equal(saved.createdAt, "");
202
+ assert.equal(saved.createdBy, "");
203
+ assert.equal(saved.contactNumber, "+65 61234567");
204
+ assert.deepEqual(saved.sites, []);
205
+ assert.equal(saved.createdBySpId, "");
206
+ });
207
+
163
208
  record("4. and the guard is unchanged — no session is still refused", async () => {
164
209
  const res = await h.api(`/organizations/${id.ownOrg}`, {
165
210
  method: "PUT",
@@ -175,6 +220,7 @@ async function seed(h) {
175
220
  const now = new Date().toISOString();
176
221
  const otherOrg = new ObjectId();
177
222
  const ownOrg = new ObjectId();
223
+ const legacyOrg = new ObjectId();
178
224
 
179
225
  const orgs = h.db.collection("organizations");
180
226
 
@@ -202,6 +248,24 @@ async function seed(h) {
202
248
  contact: "+65 00000000",
203
249
  createdAt: now,
204
250
  },
251
+ // A row in the exact shape a lead read out of production: the legacy
252
+ // `organization-api` field set, with the three timestamps/ids as EMPTY
253
+ // STRINGS. Deliberately NOT covered by the unique index above — its name is
254
+ // its own — so case 5 measures the document shape and nothing else.
255
+ {
256
+ _id: legacyOrg,
257
+ name: "Legacy Shaped Org",
258
+ email: "os-legacy@e2e.example.com",
259
+ contactNumber: "+65 61234567",
260
+ description: "",
261
+ sites: [],
262
+ status: "active",
263
+ logo: [],
264
+ createdBySpId: "",
265
+ createdAt: "",
266
+ updatedAt: "",
267
+ createdBy: "",
268
+ },
205
269
  ]);
206
270
 
207
271
  const users = await h.db.collection("users").insertMany([
@@ -216,5 +280,14 @@ async function seed(h) {
216
280
  // holds at step 1.
217
281
  await orgs.updateOne({ _id: ownOrg }, { $set: { createdBy: owner } });
218
282
 
219
- return { owner, ownOrg, otherOrg };
283
+ // The legacy row's `createdBy` is "" — that road cannot reach it, so the
284
+ // owner reaches it the ordinary way, by membership.
285
+ await h.db.collection("members").insertOne({
286
+ user: owner,
287
+ org: legacyOrg,
288
+ type: "organization",
289
+ status: "active",
290
+ });
291
+
292
+ return { owner, ownOrg, otherOrg, legacyOrg };
220
293
  }
@@ -407,7 +407,7 @@ describe("safety alerts, end to end", { concurrency: 1 }, () => {
407
407
  assert.equal(categorySupportsChannel(category, "email"), false, category);
408
408
  assert.ok(
409
409
  notificationCategory(category).channelNote?.email,
410
- `${category} must explain the dimmed email switch`,
410
+ `${category} must record why it offers no email switch`,
411
411
  );
412
412
  }
413
413
  },