@7365admin1/core 3.47.1-staging.130 → 3.48.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.
Files changed (45) hide show
  1. package/CHANGELOG.md +891 -0
  2. package/dist/index.d.ts +22 -0
  3. package/dist/index.js +128 -1
  4. package/dist/index.js.map +1 -1
  5. package/dist/index.mjs +128 -1
  6. package/dist/index.mjs.map +1 -1
  7. package/package.json +1 -1
  8. package/test/staff-console-authz.test.mjs +9 -0
  9. package/test/terms-acceptance-list.test.mjs +204 -0
  10. package/.changeset/camera-engagement-site-scope.md +0 -15
  11. package/.changeset/camera-entitlement-engagement.md +0 -45
  12. package/.changeset/camera-functions-for-patrol.md +0 -42
  13. package/.changeset/camera-integration-layer.md +0 -60
  14. package/.changeset/camera-list-site-scoping.md +0 -42
  15. package/.changeset/camera-monitoring-wall.md +0 -32
  16. package/.changeset/camera-probe-cache-and-substream-fallback.md +0 -46
  17. package/.changeset/camera-setup-from-recorder.md +0 -43
  18. package/.changeset/camera-write-authorization.md +0 -35
  19. package/.changeset/console-audit-client-status.md +0 -20
  20. package/.changeset/console-audit-trail.md +0 -5
  21. package/.changeset/console-subscription-setup.md +0 -5
  22. package/.changeset/customer-site-property-details.md +0 -42
  23. package/.changeset/dahua-credential-logging.md +0 -19
  24. package/.changeset/dahua-spec-defects.md +0 -20
  25. package/.changeset/dashboard-truth-and-camera-health-claim.md +0 -104
  26. package/.changeset/default-staff-role-lookup.md +0 -5
  27. package/.changeset/hid-card-pin-authorization.md +0 -35
  28. package/.changeset/hid-facial-sync-authorization.md +0 -12
  29. package/.changeset/hid-physical-card-lifecycle.md +0 -8
  30. package/.changeset/hid-reader-handler-authorization.md +0 -21
  31. package/.changeset/marketplace-order-notification-categories.md +0 -39
  32. package/.changeset/members-paging-skip.md +0 -18
  33. package/.changeset/notification-preferences.md +0 -20
  34. package/.changeset/notifications-session-scoping.md +0 -17
  35. package/.changeset/occurrence-and-patrol-defects.md +0 -24
  36. package/.changeset/promo-code-management.md +0 -5
  37. package/.changeset/service-provider-invite-approval.md +0 -31
  38. package/.changeset/service-provider-invite-pending-check.md +0 -17
  39. package/.changeset/service-provider-org-filter.md +0 -17
  40. package/.changeset/site-name-similarity-check.md +0 -26
  41. package/.changeset/staff-console-authorization.md +0 -29
  42. package/.changeset/suspend-blocks-sign-in.md +0 -20
  43. package/.changeset/terms-acceptance-per-user.md +0 -5
  44. package/.changeset/users-list-staff-guard.md +0 -17
  45. package/.changeset/users-v2-list-staff-guard.md +0 -13
package/CHANGELOG.md CHANGED
@@ -1,5 +1,896 @@
1
1
  # @iservice365/core
2
2
 
3
+ ## 3.48.0
4
+
5
+ ### Minor Changes
6
+
7
+ - d257156: Read the Terms and Privacy Policy acceptance record as a list.
8
+
9
+ Who accepted the platform Terms, and when, was already stored - `acceptedTerms`
10
+ and `acceptedTermsAt` on the user - but the only way to read it was
11
+ `GET /terms/:user/status`, one named user at a time. The staff console has no
12
+ list of user ids to walk, so it could not show the acceptance record at all, and
13
+ nobody could answer the question that matters the day new Terms are published:
14
+ who has NOT accepted them yet.
15
+
16
+ `usePlatformTermsController().getAcceptance` answers it as a paged list, filtered
17
+ by version number and by accepted / not accepted, either or both, neither
18
+ meaning everybody. With a version AND "not accepted", the answer includes the
19
+ accounts still sitting on an older version, not only the ones that never
20
+ accepted anything. A version number that was never published is a 404 rather
21
+ than an empty page, so a mistyped filter cannot read as "nobody has accepted".
22
+
23
+ `requirePlatformStaff` gates it, resolved from the session - the same gate the
24
+ console audit list and the platform user list carry, and for the same reason: it
25
+ is a cross-tenant read by definition. Staff-level, not owner-level; the owner
26
+ tier stays reserved for the actions that change a client's service.
27
+
28
+ Each row carries an id, a display name, the account type and status, the
29
+ accepted version and the timestamp. No e-mail, no contact number, no birthday -
30
+ a consent record does not need them, and this list spans every client at once.
31
+ Soft-deleted accounts are excluded. The version number is joined at read time,
32
+ so a row cannot hold a stale copy of it, and the join runs after the page is
33
+ cut, on at most `limit` rows.
34
+
35
+ Paging is `$skip` before `$limit`, the offset derived from the page - the defect
36
+ fixed in the members list is not repeated here. Not cached: whether a person has
37
+ accepted the current Terms is the one question where a stale answer is worse
38
+ than a slow one.
39
+
40
+ Additive only. `GET /terms/:user/status`, `POST /terms/:user/accept`,
41
+ `GET /terms/latest`, `GET /terms/:id` and `POST /terms` are untouched, and no
42
+ existing response shape or status code changed. The route that mounts this is a
43
+ separate change in `iservice365-API-core`.
44
+
45
+ ### Patch Changes
46
+
47
+ - 821d92a: Suspending or reactivating a client is now recorded in the console audit trail.
48
+
49
+ The audit trail (`console-audit`) already carried `client.suspended` and
50
+ `client.reactivated` with their labels and their allow-listed fields, but nothing
51
+ wrote them: `organization.controller.ts` was deliberately left unhooked while the
52
+ suspend work was still an open PR. Now that it has landed, `updateStatus` records
53
+ one row per status change - the direction taken from the status that was actually
54
+ written, the actor taken from the owner guard's return value and never from the
55
+ request body, and only `status` kept from the change.
56
+
57
+ The row is written after the status has changed and before the reply, the same
58
+ order as every other recorded action, so a row can never describe something that
59
+ did not happen. `recordConsoleAction` still swallows its own failures, so a lost
60
+ audit row cannot undo a suspension.
61
+
62
+ `client.created` stays unconnected on purpose and its reminder is unchanged.
63
+
64
+ - 3817ceb: Staff console: record who did what, and add a staff-only endpoint to read it back
65
+ - 1e10125: Authorize the HID facial-data sync handler in its own body
66
+
67
+ `syncReaderFacialData` reached `staging` with no authorization check inside the
68
+ handler. The API-core router does apply `requireAuth` + `authorizeManage`, so it
69
+ was not reachable by an ordinary signed-in user — but this is a published
70
+ package, and the guard and the handler sat in two different repositories. It now
71
+ runs the same `manage`-level check the router applies, with the site taken from
72
+ the stored reader. The guard test pins it by name.
73
+
74
+ - 5401d35: HID: the five reader endpoints added for production readiness now authorise in their own handlers
75
+
76
+ `listReaderUsers`, `listReaderAccessLogs`, `configureReaderIntegration`,
77
+ `setReaderMonitor` and `setOperatingMode` had no check in their bodies. They are
78
+ guarded today by `authorizeView` / `authorizeManage` in the API-core router, so
79
+ this is not a live hole — but the guard and the handler sit in two different
80
+ repositories, and this is a published package: a router edit or a second
81
+ consumer mounting `useHidAmicoController()` would leave an endpoint that reaches
82
+ a physical door reader with nothing in front of it.
83
+
84
+ Each now calls `authorizeHidAccess` itself, at the level the router already
85
+ applies — `view` for the two lists, `manage` for the three writes — with the
86
+ site resolved from the stored reader, never from the request. No caller's
87
+ authority changes.
88
+
89
+ The guard test now covers all fifteen HID handlers and names the unguarded ones
90
+ in its failure message.
91
+
92
+ - 1bcf58c: Fix GET /api/members returning the same page over and over
93
+
94
+ `member.repo.ts` `getAll` built its aggregate with `{ $limit: limit }` and no
95
+ `$skip`. `paginate()` only _labels_ the range — it returns `items` untouched and
96
+ computes `pageRange` arithmetically — so the server answered page 2 with page
97
+ 1's rows under an "11-20 of 42" heading. Every Members screen in every app was
98
+ affected, not one screen.
99
+
100
+ `{ $skip: page * limit }` is added ahead of the limit, matching the two
101
+ `getOrgs*` reads in the same file that were already correct. `page` is
102
+ zero-based by then, and the cache key already varies by page, so no cache
103
+ change is needed.
104
+
105
+ The response shape is unchanged: `{ items, pages, pageRange }`.
106
+
107
+ - 00bc18e: Fix the ignored serviceProviderOrgId filter on GET /api/service-providers
108
+
109
+ `service-provider.controller.ts` `getServiceProviders` validated
110
+ `serviceProviderOrgId` in its Joi schema but read
111
+ `req.query.serviceProviderId`. Joi rejects unknown keys, so no caller could
112
+ ever send the name that was read — the value was permanently `undefined` and
113
+ the documented filter was silently ignored, answering with every service
114
+ provider rather than the one organisation's.
115
+
116
+ The read now uses the key the schema declares. The repository already handled
117
+ `serviceProviderOrgId` correctly, so nothing below the controller changes.
118
+
119
+ Raised in web-app-org #142.
120
+
121
+ - a1b964a: Suspending a client now actually blocks sign-in for that client's people.
122
+
123
+ Both login services (`auth.service.ts` and `auth-v2.service.ts`) checked only the
124
+ person's own account status and never looked at the organisation, so suspending a
125
+ client stopped nobody. Every session either service mints - login and mobile
126
+ refresh, v1 and v2 - now resolves the caller's organisation (staff and guards via
127
+ `members`, residents via `persons`, both directly or through their site) and
128
+ refuses with its own wording when that client is suspended. Seven365 staff
129
+ accounts are exempt.
130
+
131
+ Suspend and reactivate (`PATCH /api/organizations/:id/status`) move from the
132
+ staff gate to a new owner-only gate, `requirePlatformOwner`.
133
+
134
+ The hourly subscription job gains a second rule: a subscription whose `endDate`
135
+ passed more than `SUBSCRIPTION_END_GRACE_DAYS` (default 7) ago is suspended. It
136
+ is inert unless `SUBSCRIPTION_AUTO_SUSPEND_ENABLED` is set to "true".
137
+
138
+ - 8d1d5c0: Require Seven365 staff to read the platform-wide user list
139
+
140
+ `GET /api/users` was `requireAuth` only. It is not filtered by organisation or
141
+ by site, so it returns every account on the platform — every client's residents,
142
+ guards and cleaners — and any signed-in account could page through the lot.
143
+
144
+ It now calls `requirePlatformStaff`, the same gate the rest of the staff console
145
+ uses. No second mechanism is introduced.
146
+
147
+ The per-user endpoints in the same controller (`getById`, `getByEmail`,
148
+ `getUsersByOrgId`, the profile and password writes) are deliberately unchanged —
149
+ they are a person's own account, not a cross-tenant read — and a test pins that
150
+ so a later sweep cannot close them by reflex.
151
+
152
+ - 3c90886: Require Seven365 staff to read the platform-wide user list on /api/users/v2 too
153
+
154
+ `/api/users` and `/api/users/v2` are separate mounts onto separate controllers,
155
+ each with its own copy of the same unfiltered platform-wide list. Gating only
156
+ v1 left the gate bypassable by adding `/v2` to the URL.
157
+
158
+ `user-v2.controller.ts` `getUsers` now calls `requirePlatformStaff`, the same
159
+ gate. The v2 per-user endpoints are unchanged, and the test that pins the v1
160
+ profile endpoints as ungated now covers both controllers.
161
+
162
+ ## 3.47.0
163
+
164
+ ### Minor Changes
165
+
166
+ - 35e1ba6: Camera functions for Virtual Patrol, proxied server-side.
167
+
168
+ Adds authenticated camera endpoints so the apps can use a site camera without ever
169
+ holding its credential. Dahua devices authenticate with digest and the cleartext
170
+ password on every request (HTTP API V3.37 §3.4) and the specification offers no
171
+ session or usable token, so the only safe shape is a server-side proxy.
172
+
173
+ - `useCameraViewController` — snapshot, snapshot-to-file for evidence, and
174
+ per-recorder health.
175
+ - A camera record does not hold a device address: `site.cameras.host` is the CCTV
176
+ relay's page URL and its numeric last path segment is the stream channel. A
177
+ camera is therefore resolved as **recorder (from `CAMERA_RTSP_DEVICES`, keyed by
178
+ relay authority) + channel (from the stored host)**. The address and credential
179
+ live in deployment configuration only — never in the database, never in a client.
180
+ - Pictures come off the video stream (one `ffmpeg` frame per snapshot, substream
181
+ for a tile, main stream for an evidence capture). The recorder in use exposes
182
+ RTSP only; its HTTP CGI ports are not reachable, so `snapshot.cgi`, firmware
183
+ (§4.6.14), device clock (§4.6.2) and PTZ (§8.1.5) cannot be used against it.
184
+ Firmware and clock are reported as unavailable with a reason, and PTZ refuses.
185
+ `GET /site-cameras/capabilities` reports `ptz: false` whatever
186
+ `CAMERA_PTZ_ENABLED` says, so a client hides the control rather than drawing a
187
+ pad whose every press fails.
188
+ - A camera whose relay has no configured recorder, or whose address carries no
189
+ channel, is refused up-front with a reason rather than failing as a blank picture.
190
+ - At most `CAMERA_MAX_CONCURRENT_GRABS` (4) pictures are taken at once across the
191
+ process, so the endpoint cannot fill the API host with ffmpeg or overpull the
192
+ recorder; over that, the caller is told the cameras are busy.
193
+ - Access is decided by the caller's site/org membership and their role's
194
+ permissions, never by the camera id in the URL.
195
+ - ANPR units are refused: ANPR belongs to visitor and vehicle management, not to
196
+ Virtual Patrol or CCTV.
197
+ - Camera host, username, password and the RTSP URL are never returned and never
198
+ logged; ffmpeg's stderr is discarded because it echoes the credential.
199
+ - No retry on a camera call: the device locks an account for 1800 s after 3
200
+ failures in 30 s (§4.7.x).
201
+
202
+ Requires `ffmpeg` on the API host (or `CAMERA_FFMPEG_PATH`); where it is absent the
203
+ snapshot endpoints answer with "Video tooling is not available on this server."
204
+
205
+ - a2bfbb1: Camera integration layer: a capability descriptor, a transport registry, and the
206
+ Dahua device HTTP adapter (written, tested, switched off).
207
+
208
+ Every camera in the wall and status responses now carries a `capabilities`
209
+ descriptor: `liveVideo`, `stillFrame`, `digitalZoom`, `ptz`, `presets`,
210
+ `playback`, `events`, `audio`, `deviceInfo`, each `supported` / `unsupported` /
211
+ `unknown` with a machine-readable reason code and one sentence. A client renders
212
+ itself from it without knowing anything about relays, recorders or networks.
213
+ `unknown` is distinct on purpose: "we have not been allowed to ask" is not the
214
+ same fact as "this camera cannot".
215
+
216
+ Capabilities resolve through a transport registry instead of the service picking
217
+ a path. `RELAY_PLAYER` is the stored player-page URL rendered in a WebView (the
218
+ live product's picture path, live video only, no control channel). `RTSP_FRAME`
219
+ is one ffmpeg still off the recorder. `DEVICE_HTTP` is the camera's own CGI API.
220
+ Registration order is preference order, so one camera serves live video over the
221
+ relay and evidence stills over RTSP at the same time, and adding a transport
222
+ later needs no service change.
223
+
224
+ `DEVICE_HTTP` covers device info/version, snapshot, PTZ (continuous, absolute,
225
+ stop), presets (list and recall), recorded-file query with playback URL
226
+ construction, and event subscription - digest auth only, values percent-encoded,
227
+ 403 treated as bad credentials and 401 as an ordinary challenge. It contacts
228
+ nothing today: the recorder in this estate answers on RTSP only. Enabling it is
229
+ configuration, not a code change.
230
+
231
+ A shared, persistent authentication failure budget is mandatory on that path. The
232
+ device locks an account for 1800 s after 3 failed logins in 30 s, so failures are
233
+ counted per device in the cache, the budget stops at 2, and once spent no request
234
+ leaves the process. Nothing retries anywhere.
235
+
236
+ Mutating operations are structurally unreachable while disabled: the adapter is
237
+ `null` without `CAMERA_DEVICE_HTTP_ENABLED`, and its `control` object is `null`
238
+ without `CAMERA_DEVICE_CONTROL_ENABLED`. Movement codes are an allow-list;
239
+ `SetPreset`, `ClearPreset`, tours, patterns, `configManager` writes and reboot are
240
+ absent.
241
+
242
+ Capability probing is read-only, cached, budget-aware, never throws, and happens
243
+ only on a deliberate single-camera status request - never as a side effect of a
244
+ list.
245
+
246
+ `host` is now returned for `type: "ip"` cameras on the wall response. It is the
247
+ video relay's player-page URL, not a device address and not a credential, and
248
+ `GET /site-cameras` already returns it to every authenticated caller. `anpr`
249
+ records, whose `host` is a real device endpoint, are excluded by the allow-list
250
+ itself.
251
+
252
+ New configuration, all optional and all off by default: `CAMERA_DEVICE_HTTP`
253
+ (JSON keyed by relay authority, credential referenced by environment variable
254
+ name), `CAMERA_DEVICE_HTTP_ENABLED`, `CAMERA_DEVICE_CONTROL_ENABLED`,
255
+ `CAMERA_DEVICE_HTTP_TIMEOUT_MS` (8000),
256
+ `CAMERA_DEVICE_HTTP_PROBE_TTL_SECONDS` (900). See
257
+ `docs/camera-integration-config.md`.
258
+
259
+ Existing responses keep every field they had, with the same wording, so current
260
+ consumers are unaffected.
261
+
262
+ - e11fbb5: CCTV monitoring wall: a site-scoped camera list and a batched health sweep.
263
+
264
+ Two additions to the camera proxy so a supervisor can watch a whole site rather
265
+ than one checkpoint at a time.
266
+
267
+ `getSiteWall` returns the cameras of one site from `site.cameras` - the
268
+ collection the real cameras are in - together with the polling intervals and
269
+ fan-out caps the client should use. No device is contacted, so opening a wall is
270
+ one database read. Entitlement is by site membership, not by the id in the URL,
271
+ and a caller outside the site gets the same answer as one asking for a site that
272
+ does not exist.
273
+
274
+ `getSiteHealth` probes the cameras currently on screen with at most
275
+ `CAMERA_WALL_MAX_CONCURRENT_PROBES` in flight. Reachability is a **per-recorder**
276
+ answer, cached per recorder: twelve of this estate's cameras are twelve channels
277
+ on one device, so a full wall is one connect rather than one per tile. It never
278
+ throws for a camera that is down; unreachable is the answer, and a camera whose
279
+ relay has no configured recorder is refused with a reason instead.
280
+
281
+ Firmware version and device clock are reported as unavailable with a reason: they
282
+ are HTTP CGI facts and the recorder in use exposes RTSP only.
283
+
284
+ Snapshots now record when a camera last returned a picture, so a tile can say
285
+ "last frame 40 minutes ago" instead of "never".
286
+
287
+ New, all optional and conservative by default: `CAMERA_WALL_POLL_MS` (5000),
288
+ `CAMERA_SINGLE_POLL_MS` (2000), `CAMERA_WALL_MAX_TILES` (9),
289
+ `CAMERA_WALL_MAX_CONCURRENT_PROBES` (4), `CAMERA_HEALTH_CACHE_SECONDS` (20).
290
+
291
+ - 2d6bc0e: CCTV reliability: cache the capability probe per channel, and stop reporting a
292
+ missing sub-stream as a dead camera.
293
+
294
+ Two defects in the camera proxy, both of which made one camera answer for
295
+ another.
296
+
297
+ **The capability probe was cached per recorder.** The key was `probe:<baseUrl>`,
298
+ so the first camera anyone opened decided `ptz` and `presets` for every channel
299
+ on that recorder until the TTL expired (default 900s). On this estate twelve
300
+ cameras are twelve channels on one device, and they are not all the same model.
301
+ The key is now `cameraProbeCacheKey(baseUrl, channel)` and every reader passes a
302
+ channel - `refreshProbe`, the health sweep, and the monitoring wall, whose own
303
+ de-duplication `Map` was keyed by recorder too and would have re-created the
304
+ same fault a layer above the cache. Cache lifetime and eviction are unchanged.
305
+
306
+ Only `ptz` and `presets` are channel-specific. `reachable`, `lockedOut`,
307
+ `softwareVersion` and `deviceType` are recorder facts that answer identically on
308
+ every channel, which is what `deviceInfo`, `playback` and `events` read, and a
309
+ test pins that so it stays true. The cost is at most one probe per channel per
310
+ TTL, and only on the deliberate single-camera status path - the wall and the
311
+ health sweep still never probe.
312
+
313
+ **A channel with no sub-stream failed exactly like a dead camera.** An
314
+ ONVIF-attached third-party camera frequently publishes no sub-stream, and the
315
+ recorder answers that channel with no picture, which surfaced as "The camera did
316
+ not return a picture." - indistinguishable from a camera that is off.
317
+ `getSnapshot` now goes through `grabWithSubStreamFallback`: the sub-stream
318
+ first, then exactly one main-stream attempt if and only if the failure is
319
+ `camera-no-picture`. The result carries `stream` and `fellBackToMain`, exposed
320
+ on the snapshot response as `X-Camera-Stream` and `X-Camera-Stream-Fallback`
321
+ alongside the existing `X-Camera-Snapshot-Cached`, so the fallback is explicit
322
+ rather than silent and a client can say "showing full resolution: this camera
323
+ has no low-bandwidth stream". A `nosub:<cameraId>` marker keeps the wasted
324
+ attempt to once per TTL, and a channel later given a sub-stream heals itself.
325
+
326
+ A refused credential, a timeout, a busy server, an oversized picture or a
327
+ missing ffmpeg never falls back - one attempt each, unchanged. Patrol evidence
328
+ (`hd: true`) already asks for the main stream and never enters the fallback.
329
+
330
+ Per-camera stream configuration is the better long-term answer but needs a
331
+ schema field, a form, an API surface and a migration for the existing cameras;
332
+ the automatic fallback needs no data change and is raised as a follow-up.
333
+
334
+ - 2b23b62: Camera setup from the recorder and channel, and a read-only test before saving.
335
+
336
+ A CCTV camera record stores a video-relay player-page URL. Nothing printed on a
337
+ camera or shown in a recorder's own screens tells anybody what that URL is, so
338
+ adding a camera meant asking whoever runs the video service for one address per
339
+ camera and pasting it in unverified.
340
+
341
+ `POST /site-cameras` and `PATCH /site-cameras/id/:id` now also accept
342
+ `recorderHost`, `recorderPort` and `channel`. The server reads the existing
343
+ `CAMERA_RTSP_DEVICES` map backwards - relay authority to recorder becomes
344
+ recorder to relay authority - and derives the same `https://<relay>/<channel>`
345
+ address it has always stored. There is deliberately no second map and no new
346
+ stored field: the recorder fields are consumed by the controller and never reach
347
+ the model, so `site.cameras` keeps exactly the shape it has today.
348
+
349
+ Additive and ordered so: a request that sends `host` behaves exactly as it
350
+ always has, so every camera configured that way and anyone holding a ready-made
351
+ address is unaffected. A recorder with no configured entry is refused with a
352
+ sentence a property manager can act on rather than a code.
353
+
354
+ New: `POST /site-cameras/site/:siteId/test` - does this address actually show a
355
+ picture? Site-scoped because the camera does not exist yet, and it requires
356
+ `site-settings:manage-cctv-camera`. The probe is read only: one TCP connect with
357
+ no credential, then at most one video frame off the stream. There is no mutating
358
+ call in the path.
359
+
360
+ It is rationed before it can reach a device - one test per camera per 15 s, four
361
+ per recorder per 5 minutes - and it consults the shared authentication failure
362
+ budget first, so pressing the button cannot walk a recorder into its own
363
+ 3-failures-in-30-seconds, 1800-second lockout. It answers with one of five plain
364
+ sentences and never an address, port, credential or device message.
365
+
366
+ `ffmpeg`'s stderr is now read for one fact - was the handshake rejected - and
367
+ discarded chunk by chunk without being accumulated, logged or returned, so "the
368
+ recorder refused the credential" can be told apart from "the channel is not
369
+ sending video". Snapshot failures gain that distinction too.
370
+
371
+ `authorizeSite` takes an optional permission list; existing callers are unchanged
372
+ and still require the camera view permissions.
373
+
374
+ - 62e4eb6: Authorize creating, editing and deleting a site camera.
375
+
376
+ `POST /site-cameras`, `PATCH /site-cameras/id/:id` and `DELETE
377
+ /site-cameras/id/:id` carried `requireAuth` and nothing else. There was no
378
+ permission check and no site scoping, so any signed-in account in any
379
+ organisation could add a camera to a site it has no relationship with, rewrite
380
+ another customer's camera address, or hard-delete one - the delete is a real
381
+ `deleteOne`, not a soft delete, and it reported success whether or not it matched
382
+ anything.
383
+
384
+ All three now go through `authorizeSite`, the same rule the read paths already
385
+ use: the caller must be a member of the camera's site, and their role must hold
386
+ the permission the Settings panel that draws the button is drawn on -
387
+ `site-settings:manage-cctv-camera` for a CCTV camera, `site-settings:manage-anpr-camera`
388
+ for an ANPR one. One endpoint serves both panels, so the permission follows the
389
+ camera's own `type` rather than being one string for both; asking only for the
390
+ CCTV string would have refused a role provisioned to manage ANPR alone.
391
+
392
+ For `PATCH` and `DELETE` the site and the type are read off the stored camera,
393
+ never off the request - `schemaUpdateSiteCamera` carries neither, and a caller
394
+ must not be able to supply either. A camera the caller may not reach is reported
395
+ exactly as a camera that does not exist, so the difference cannot be used to
396
+ enumerate camera ids.
397
+
398
+ Measured on staging before shipping: of the 107 users entitled to a site that has
399
+ a CCTV camera, 12 pass this gate and every one of them passes on the `*`
400
+ wildcard. No role that would now be refused holds any `site-settings:` string at
401
+ all, so no account that can open the CCTV or ANPR panel today is refused by this
402
+ change.
403
+
404
+ No response shape, status code or field changed for a permitted caller.
405
+
406
+ - 0af88b8: Add the staff console endpoints that set up, change and read back a client subscription, and the optional plan, start date, end date and billing mode fields they store.
407
+ - 348fde3: Stop the dashboards reporting figures nothing measured, and stop a recorder
408
+ socket being reported as camera health.
409
+
410
+ Five values were being returned in a shape that made "we measured this" and "we
411
+ never measured this" indistinguishable. Each is now either computed from real
412
+ data or returned as an explicit `null` with a reason a client can show.
413
+
414
+ **Patrol Compliance showed 100% on a site with no patrol data.**
415
+ `complianceRate` fell back to `100` when the checkpoint total was 0, so a site
416
+ with no patrol logs drew the same tile a perfect week draws - and a failed
417
+ request drew 0%. `calculateComplianceRate` now returns `null` when no checkpoint
418
+ was due, alongside `patrolCompliance.checkpointsDue` and an
419
+ `unavailableReason`. The Virtual Patrol process itself is untouched; this is
420
+ reporting only.
421
+
422
+ **"0 high severity" was a hard-coded literal**, at `openIncidents.highSeverity`
423
+ in both the security and property dashboards, on every site, on every day. It
424
+ read as "we checked your incidents and none of them are serious". Incident
425
+ reports record no severity at all - `TIncidentReport` has no severity, priority
426
+ or criticality field, and the only `highPriority` flags in this package are on
427
+ work orders and feedbacks - so there is nothing to count. It now returns `null`
428
+ with `highSeverityUnavailableReason`. `highSeverityIncidentCount` counts rather
429
+ than hard-returning `null`, so the tile starts telling the truth on its own the
430
+ day the incident form records a severity. Nothing is guessed in the meantime.
431
+
432
+ **The trend percentage compared the wrong period, in the wrong timezone.** The
433
+ `+/-%` beside every KPI tile was always today-vs-yesterday even on the Week and
434
+ Month views, so a month's count sat next to a yesterday comparison, unlabelled.
435
+ And the day boundary came from `new Date()` + `setHours(0,0,0,0)` - the HOST's
436
+ midnight - while `formatEndDate` and the period range beside it force
437
+ Asia/Singapore. On a UTC container the two halves disagree by eight hours:
438
+ "today" silently loses SGT 00:00-08:00 while "yesterday" keeps its full 24, so
439
+ the comparison was biased before any data was counted.
440
+
441
+ `getPeriodRange` / `getPreviousPeriodRange` (new,
442
+ `src/utils/dashboard-metrics.util.ts`) state Asia/Singapore explicitly and take
443
+ an injectable clock. Week now compares against the previous ISO week and month
444
+ against the previous month, in all three dashboards plus the facility-booking
445
+ tile. The boundary is correct whatever the host is set to; the host's own `TZ`
446
+ still wants reading by someone with DigitalOcean access, but only to size the
447
+ damage already done.
448
+
449
+ **A missing prior period now yields no percentage.**
450
+ `calculatePercentageChange` returned `100` when the prior period was empty and
451
+ `0` when both were empty, so a first-ever record read as growth and a site where
452
+ nothing has ever happened read as measured stability. Both now return `null`.
453
+
454
+ **"Supply Alert" is not an alert.** The query is `sort by qty ascending, limit
455
+ 3` with no threshold, because `site.supplies` records no stock level to compare
456
+ against - no `minQty`, no `reorderLevel`, no `threshold`, on any model in this
457
+ package. 900 gloves appeared under a heading that said "alert". No threshold is
458
+ invented: the payload now carries `basis: "lowest-quantity"` and
459
+ `thresholdUnavailableReason` so no client can present the list as a low-stock
460
+ warning. The client already relabels the tile "Lowest Stock".
461
+
462
+ **Camera health was a socket on the recorder, presented as the camera's.**
463
+ `probeDevice` is a bare TCP connect to the recorder's RTSP port, cached per
464
+ recorder. That caching is deliberate and worth keeping - twelve channels on one
465
+ device is one connect, not twelve - but it means one dead camera among twelve
466
+ stayed invisible (the recorder still answers, so every channel returned
467
+ `reachable: true`, `health: "ok"`, `reason: null`), and a relay outage read as
468
+ twelve healthy tiles because the relay is never probed.
469
+
470
+ A true per-camera liveness check is not available on this path: it would mean an
471
+ authenticated per-channel RTSP frame grab for every tile on every sweep, which
472
+ is device contact this change is in no position to make or to test and is enough
473
+ of it to reach the recorder's own lockout. So none is faked. Every health answer
474
+ now carries `cameraHealthClaim` - `verified` (`"recorder-video-port"` or
475
+ `"nothing"`), a `verifiedDescription` sentence, and `cameraVerified` /
476
+ `relayVerified` as explicit `null`s - so the response states exactly what was
477
+ checked and cannot be read as a stronger claim. `health` and `reachable` keep
478
+ their existing values and meanings, so the web wall (which already labels this
479
+ "Recorder responding") and the mobile monitoring screen are unaffected. A camera
480
+ that resolves to no recorder reports `"nothing"` rather than a check that
481
+ failed, because no request left the server.
482
+
483
+ Nothing else in the health descriptor overstates itself: `firmwareVersion`,
484
+ `deviceTime` and `driftSeconds` already return `null` with
485
+ `detailUnavailableReason`, and capabilities already carry an explicit
486
+ `"unknown"` state with a reason.
487
+
488
+ **Response contract:** `patrolCompliance.count`, `openIncidents.highSeverity`
489
+ and every tile's `percentage` may now be `null`. Checked against the current
490
+ published client rather than assumed: `DashboardMain.vue` coerces through
491
+ `toNumber(value ?? 0)`, so nothing crashes and nothing prints "null". A `null`
492
+ percentage is coerced to 0, which `v-if="card.percentage"` treats as falsy, so
493
+ the trend pill correctly disappears - the intended behaviour, for free. But a
494
+ `null` compliance rate still draws "0%" and a `null` high-severity count still
495
+ draws "0 high severity" until the layer renders the not-available. So on a site
496
+ with no patrol data the tile moves from a fabricated 100% to a fabricated 0%
497
+ until that lands; both are wrong, and the API is now the half that is right.
498
+ The client-side half is `layer-common` #1229 (open).
499
+
500
+ Dead code removed as a consequence: the local `formatEndDate` and
501
+ `getSiteDayRange` in `new-dashboard.repo.ts` have no remaining callers.
502
+
503
+ Deliberately not changed: the duplicate current-period aggregations (the trend's
504
+ current-side query now matches the tile's own count query and could be dropped,
505
+ but that is an optimisation, not a correctness fix); the Dahua URL convention;
506
+ and the disabled device-control capabilities, which stay off.
507
+
508
+ - f334ae3: Add dedicated physical HID card APIs for PACS and CSN credentials, including
509
+ manual assignment, reader enrollment, cancellation, verified removal, duplicate
510
+ protection, reader configuration checks, identity lookup metadata, and audited
511
+ device events.
512
+ - 907df46: Hold property-manager service-provider invitations for Seven365 approval, and give an invitation a life
513
+
514
+ A property management company inviting a service provider used to reach that
515
+ provider immediately. It now waits for the Seven365 super admin, and the
516
+ provider is told nothing at all until it is approved — no email, no in-app
517
+ message, and the invitation is not even readable to them. Approval is the one
518
+ place an invitation reaches the provider, so there is a single function that can
519
+ leak an unapproved one. An invitation Seven365 sends itself is not held.
520
+
521
+ An invitation now has seven states instead of two: waiting for Seven365, waiting
522
+ for the provider, accepted, not approved (with a reason the property manager
523
+ sees word for word), declined, cancelled and expired. Every change goes through
524
+ one conditional update that moves the row only from an expected state and
525
+ appends an immutable history line in the same write — so approving or accepting
526
+ twice writes nothing the second time, and no path can move an invitation
527
+ silently. Deleting is a soft delete: the row leaves the property manager's list,
528
+ the record and its history stay and remain visible to Seven365.
529
+
530
+ Authorization, which this endpoint did not have: the caller is resolved from the
531
+ session and must belong to the organisation the invitation is sent for — being
532
+ signed in used to be enough to invite a provider to any organisation's site.
533
+ Approving and rejecting require the Seven365 super-admin membership (an org-less
534
+ `members` row of type `admin` whose role is also of type `admin`), not an email
535
+ address, so it behaves the same in staging and production.
536
+
537
+ An already-onboarded provider still never sees sign-up or a one-time code, and
538
+ the sign-up path for a brand-new company is unchanged.
539
+
540
+ ### Patch Changes
541
+
542
+ - b5b41ea: Camera entitlement: an engagement grants a site only to an org-level membership, and permissions resolve against the granting role
543
+
544
+ Two follow-ups to the camera entitlement change:
545
+
546
+ - the engagement branch of `cameraGrant` ignored `members.siteId`, so a member
547
+ pinned to one site could reach every site their organisation is engaged at.
548
+ It now requires an org-level membership, matching the owning-org branch above
549
+ it: a membership pinned to a site never grants a different site.
550
+ - `getUserPermissions` accepts the granting membership's `role`, so a caller
551
+ holding several memberships in one organisation is no longer resolved by
552
+ whichever member document Mongo returns first.
553
+
554
+ - 91ba6e8: Let camera authorization understand engagements, and resolve it against the
555
+ right organisation.
556
+
557
+ Three defects in the same decision, fixed together because they are the same
558
+ question: "may this person reach this site, and on whose behalf?"
559
+
560
+ **1. An engagement was not an entitlement.** The product routes a service
561
+ provider - a security agency, a property management agency - to a customer's
562
+ site through an ACTIVE `customer.sites` record (`{org: provider, siteOrg: owner,
563
+ site}`). 227 of those are active on staging, 53 of them cross-organisation. It
564
+ is the same list the web apps' own site switcher is drawn from
565
+ (`useCustomerSite().getAll()`). No camera authorization code read it, so a guard
566
+ whose agency is contracted at a site was refused the cameras of the site the
567
+ switcher had just offered them. Measured on staging: **11 people reach a site
568
+ holding an active IP camera by this route and no other, 7 of them on wildcard
569
+ `owner` / `Super Admin` roles** - and across all 200 sites the figure is 125
570
+ people, 43 of them on wildcard roles. Nobody loses access; the change is
571
+ additive.
572
+
573
+ **2. `site.org` does not exist.** `authorizeSite` and `authorizeCamera`
574
+ projected `{org: 1}` and tested `site?.org`. All 200 site documents carry
575
+ `orgId`; not one carries `org`. So the org-level branch of the rule - the branch
576
+ for the 90 membership rows (56 people) that carry no `siteId` - could never
577
+ match. It reads `orgId` now, with `org` kept as a fallback. This WIDENS access,
578
+ so it was measured before it was enabled: on staging it admits **0** additional
579
+ people to a site holding a camera, and 14 across all 200 sites.
580
+
581
+ **3. Permissions resolved against an arbitrary organisation.**
582
+ `getUserPermissions({user, org: memberships[0]?.org})` took whichever membership
583
+ Mongo returned first, which for the 22 people who belong to more than one
584
+ organisation is a coin toss - it could read the permissions of an organisation
585
+ that has nothing to do with the site being asked about. It now resolves against
586
+ the membership that actually granted access.
587
+
588
+ Also splits `entitleSite` (may this person reach this site) out of
589
+ `authorizeSite` (that, plus the permission this particular use needs), so a
590
+ plain read of a site's own camera list can be scoped without also being gated on
591
+ a permission that would strand people who are legitimately at the site.
592
+
593
+ No response field changed. No route changed. No device is contacted by any of
594
+ this.
595
+
596
+ - 21d5d74: Scope `GET /site-cameras` to the caller's own site.
597
+
598
+ The site was an OPTIONAL query parameter and nothing on the handler looked at
599
+ the session beyond `requireAuth`. So any signed-in account, in any organisation,
600
+ could page the whole estate's cameras - on staging that is all 15 active IP
601
+ cameras across 3 owner organisations, each record carrying its `host` and its
602
+ `username`. It is the plainest cross-tenant read left in the camera module.
603
+
604
+ `site` is now required, and the caller is authorised against it with
605
+ `entitleSite`: membership of the site, of its owning organisation, or an ACTIVE
606
+ `customer.sites` engagement with it. "Not yours" and "does not exist" answer
607
+ identically, so site ids cannot be enumerated from the difference.
608
+
609
+ **Every caller in the estate already sends `site`** - checked across every
610
+ repository, not assumed:
611
+
612
+ | caller | sends |
613
+ | --------------------------------------------------------------- | ----------------------------- |
614
+ | `iservice365-mobile-app-security` `pages/cctv/index.vue` (live) | `site`, `type: ip`, `page` |
615
+ | `iservice365-mobile-app-security` `useAnprEnabled` (live) | `site`, `type: anpr` |
616
+ | `iservice365-layer-common` `CameraMain.vue` (Site Settings) | `site`, `type`, `page` |
617
+ | `iservice365-layer-common` `CameraWall.vue` | `site`, `type: ip`, `page` |
618
+ | `iservice365-web-app-security` `VirtualPatrolRoutesForm.vue` | `site`, `type: ip`, `page: 1` |
619
+ | `isecure365-mobile-app` `site.service.ts` `getAnprCameras` | `site`, `type: anpr` |
620
+
621
+ So no running client breaks.
622
+
623
+ Deliberately NOT gated on a camera permission. This is the same list the site's
624
+ own Settings panel and the guard's app have always read; on staging only 25 of
625
+ the 118 people entitled to a site with a camera hold a camera-view permission,
626
+ so a permission gate here would strand people who are legitimately at the site.
627
+ Restricting the read to the caller's own sites is the fix; who among them may
628
+ look is a separate decision that needs its own measurement.
629
+
630
+ The repository's 15-minute cache key already carries the site, so a cached page
631
+ can only be served to callers authorised for that same site. No caller id was
632
+ added to the key - it would make the cache per-person for an answer that is
633
+ identical per site.
634
+
635
+ - 8dea8b8: Give the Add / Edit Site form's property details somewhere to land.
636
+
637
+ The Organization app's Add Site form collects eight fields beyond the name,
638
+ category and address: MCST plan no., UEN, date of constitution, financial year
639
+ end, billing quarter, share capital, temporary occupation permit and
640
+ certificate of statutory. **None of them has ever been stored.** On create
641
+ `schemaCustomerSite` rejects any key it does not list, so sending them returns
642
+ 400 - which is why the form quietly leaves them out of the request. On update
643
+ `updateCusSiteById` builds a whitelist of `name`, `address` and `category` and
644
+ drops the rest. The Edit dialog reads them back from the customer-site
645
+ document, so they display as empty every time.
646
+
647
+ Eight optional string fields are added to `schemaCustomerSite`, to
648
+ `TCustomerSite`, to the `MCustomerSite` projection, and to the update
649
+ whitelist. One exported list (`customerSitePropertyFields`) drives both the
650
+ create projection and the update whitelist, so the two cannot drift apart.
651
+
652
+ **Additive and optional only.** No migration, no backfill, no index change,
653
+ nothing written to any existing record. A field the caller did not send stays
654
+ absent from the document rather than being written as an empty string, so a
655
+ customer-site written before this change is byte-identical afterwards and still
656
+ validates. An empty string IS kept, because clearing a field on the Edit form
657
+ has to clear it. Unknown keys are still rejected on create - this widens the
658
+ allow-list, it does not open it.
659
+
660
+ Blast radius, checked across every repository in the organisation rather than
661
+ assumed: `POST /api/customer-sites` has exactly **one** caller in the estate
662
+ (`iservice365-web-app-org` `components/SiteForm.vue`) and `PUT
663
+ /api/customer-sites/:id` has the same one. Every other consumer - all eleven
664
+ Nuxt apps via `layer-common`'s `useCustomerSite`, the resident app, the six
665
+ service-provider mobile apps, `iservice365-mobile-app-security` and
666
+ `isecure365-mobile-app` - only ever reads (`GET`), and reads name their fields
667
+ explicitly. `addViaInvite` sends none of the eight, so the invite-acceptance
668
+ path writes exactly the document it wrote before.
669
+
670
+ Covered by `test/customer-site-property-fields.test.mjs`, which asserts the
671
+ before-and-after shape both ways round: the details are accepted and survive
672
+ into the document, and a create without them still writes none of them.
673
+
674
+ - 78a3053: Stop logging camera and mailer credentials
675
+
676
+ Six places wrote a secret or a resident's details to stdout on an ordinary code
677
+ path. `getTrafficJunction` dumped the whole `TSiteCamera` object — including the
678
+ camera's `username` and plaintext `password` — on every reconnect, and
679
+ `addPlateNumber` logged its argument, which carries the same password plus a
680
+ resident's plate number and owner name. `checkOutBySiteAndPlate` logged the
681
+ matched visitor transaction. `siteCameraRepo.updateById` logged the re-read
682
+ camera document, and `vehicleService.add` logged the whole array of a site's
683
+ cameras, both with credentials in them. `sendEmail` printed `MAILER_PASSWORD`
684
+ itself on every message sent.
685
+
686
+ All now log identifiers, counts and outcomes only, through the module's logger
687
+ rather than `console.log`; the mailer line is removed outright because it
688
+ carried nothing but the secret.
689
+
690
+ - bbd97b4: Fix three Dahua HTTP API defects found by checking our integration against the
691
+ vendor specification (V3.37):
692
+
693
+ - The ANPR reconnect loop retried authentication ten times at one-second
694
+ intervals. The device locks the account for 1800 seconds after three failed
695
+ logins in thirty, so our own recovery could take ANPR down at a site for half
696
+ an hour. Authentication now backs off well past the device's counting window,
697
+ and a network fault is distinguished from an authentication failure.
698
+ - 401 and 403 were handled backwards. Per spec §3.4, 401 is the digest
699
+ challenge (retry) and 403 is a credentials rejection (stop). We treated 403 as
700
+ an account lock and killed the listener, and counted 401s toward a "wrong
701
+ password" warning.
702
+ - `updatePlateNumber`, `bulkInsertPlateNumber` and `removePlateNumber`
703
+ interpolated values into the CGI query string unencoded, against the spec's
704
+ stated RFC 3986 requirement. A plate or owner name containing a space or "&"
705
+ broke the request or appended extra parameters to it.
706
+
707
+ - ac0c6e1: Resolve the seeded platform staff role by type as well as name, so the default staff account cannot be bound to an ordinary organisation role that happens to share the name.
708
+ - ea2f1a3: HID Amico: authorise the card, PIN, discovery and visitor-QR endpoints
709
+
710
+ Ten HID Amico handlers required a session and nothing else. The reader id in
711
+ the URL was the only thing selecting the device, and `GET /readers` returns
712
+ every reader in the estate when no `site` is supplied, so any signed-in account
713
+ could act on any reader at any site of any organisation — assign or delete a
714
+ physical access card, set or clear a keypad PIN, put a reader into card
715
+ enrolment, list a user's stored card values, or revoke a visitor's QR pass.
716
+
717
+ Each of the ten now authorises before it does anything:
718
+
719
+ - `listUserCards`, `assignUserCard`, `enrollUserCard`, `cancelUserCardEnrollment`,
720
+ `deleteUserCard` and `revokeVisitorQr` require `site-settings:manage-entry-pass`
721
+ at the site of the **stored** reader.
722
+ - `getUserPinStatus`, `setUserPin` and `deleteUserPin` allow either that same
723
+ administrator, or the person whose own device user it is — entitlement to the
724
+ reader's site plus an ownership check on `hidUserId`, so the resident and MA
725
+ mobile apps' "Access PIN" screen keeps working while a neighbour's PIN cannot
726
+ be rewritten.
727
+ - `discoverReader` now takes the `site` the reader is being added to and
728
+ authorises it, and the address it may be pointed at is constrained: an
729
+ `http(s)` origin only, no credentials, no path, and never the API host itself
730
+ or the instance-metadata range. `HID_DISCOVERY_ALLOWED_HOSTS` optionally pins
731
+ it to named hosts; unset, nothing changes operationally.
732
+
733
+ A reader that is not yours answers exactly like a reader that does not exist, so
734
+ reader ids cannot be enumerated by watching the difference.
735
+
736
+ **Consumer change required:** `POST /access-management/hid/readers/discover` now
737
+ requires `site` in the body. `HidReaderForm.vue` in `@7365admin1/layer-common` is
738
+ the only caller and must be updated to send it.
739
+
740
+ - db402eb: Add the notification categories the marketplace order flow needs, and a way to
741
+ offer a switch to a vendor organization's staff.
742
+
743
+ Every send funnels through `filterRecipients`, which hands back nobody when the
744
+ category key is not in the catalogue. There was no key for buying or selling, so
745
+ a marketplace order notification would have reached no one; and the existing
746
+ `prelovedMarketplace` key is the resident neighbour noticeboard, so reusing it
747
+ would have meant somebody muting a neighbour's bookshelf silently losing their
748
+ own order updates.
749
+
750
+ Two new keys, deliberately separate from each other and from the noticeboard:
751
+
752
+ - `marketplaceOrder` — "My Marketplace Orders", offered to residents, for the
753
+ buyer's own sub-order changes and the seller's replies.
754
+ - `marketplaceVendorOrder` — "Marketplace Sales", offered to members of an
755
+ organization that sells, for a new sub-order and the buyer's replies.
756
+
757
+ Selling is not granted by a role permission anywhere in the estate — it is a
758
+ flag on the organization (`organizations.isMarketplaceVendor`) — so there is no
759
+ permission resource that could carry the sales switch. `categoriesForPermissions`
760
+ gains an optional `isMarketplaceVendor` ground, the exact twin of the existing
761
+ `isResident`/`residentDefault` pair, and `categoriesForUser` resolves it from the
762
+ organizations the caller is already a member of. No permission string is
763
+ introduced and no role data changes.
764
+
765
+ Additive throughout: both new parameters default to false, so a caller that does
766
+ not pass them is offered precisely what it was offered before. No existing
767
+ category changed its key, label, channels, defaults or offered-set.
768
+
769
+ Both keys are `rendered: true`, so the switches exist the moment this is
770
+ published. The senders live in `iservice365-API-core` and follow immediately —
771
+ the two must reach staging in the same cycle, or the screen shows a switch that
772
+ governs nothing. Setting them `rendered: false` is the alternative, but then
773
+ nothing offers the switch while the senders CAN already deliver, which is the
774
+ worse of the two.
775
+
776
+ - c99436e: Let a person choose which notifications they get, and enforce it where they are
777
+ sent.
778
+
779
+ Adds a `notification-preferences` collection (one row per user, storing only what
780
+ they switched OFF), a catalogue of what the product can notify about and on which
781
+ routes, and an authenticated pair of endpoints for reading and changing your own
782
+ settings — the user comes from the session, never from the request.
783
+
784
+ The guard runs inside `NotificationService.send`, so a switch actually stops the
785
+ message: the in-app copy and the phone alert are filtered independently. Somebody
786
+ with no saved row is never filtered, so behaviour is unchanged until they choose
787
+ to change it.
788
+
789
+ Two notice-board senders tag their messages `module: "feedback"`; they now pass
790
+ the honest category to the guard while leaving the stored tag alone, so switching
791
+ Feedback off does not silence the notice board.
792
+
793
+ - ce464b2: Scope the notification inbox to the signed-in person.
794
+
795
+ Every handler on `/notifications` read the user id out of the query string or
796
+ the URL and acted on it without comparing it to the session, so any signed-in
797
+ account could list another person's notifications, mark them read, and read
798
+ their unread count. Whose inbox is opened now comes from the session; a request
799
+ naming somebody else is refused rather than quietly served.
800
+
801
+ `POST /notifications` writes into up to 5000 inboxes at once and had no
802
+ authorisation beyond being signed in. It now requires a Seven365 super admin.
803
+
804
+ The `user` parameter is still accepted, so no app has to be released in step
805
+ with this: every caller in the organisation already sends its own id.
806
+
807
+ - 8266484: Fix defects in the occurrence-book and patrol modules, and move their day
808
+ windows to Singapore time
809
+
810
+ - Every day window in the occurrence-book and patrol queries is now
811
+ Asia/Singapore (UTC+8), midnight to midnight. Computed in UTC, a day ran
812
+ 08:00–08:00 SGT, so an entry or a patrol scan logged at 00:30 fell into the
813
+ previous day and the guard who made it could not find it under "today".
814
+ Stored data and response shapes are unchanged; only the window moves.
815
+ - Occurrence-book date filter returned the previous day for `DD/MM/YYYY`
816
+ callers, and rejected the ISO form its own validation accepts.
817
+ - Occurrence-book pagination was collapsed to page one whenever a date filter
818
+ was set, while the response still reported the full page count.
819
+ - Occurrence-entry search by book number could never match, because `$regex`
820
+ was applied to the numeric `bookEntryCount`.
821
+ - `PUT /api/patrol-logs/id/:id` and `PUT /api/patrol-routes/id/:id` answered
822
+ HTTP 200 when the update matched no document, so a failed incident-report
823
+ attach was reported to the user as a success.
824
+ - Amending an occurrence entry whose subject or signature had been deleted
825
+ answered HTTP 500; and deleting an already-deleted patrol route answered
826
+ HTTP 500 instead of succeeding.
827
+
828
+ - 6315a67: Promo codes: enforce expiry and disabled at redemption, and let staff edit, disable and remove a code
829
+ - b762baf: Make the "an invitation is already waiting" check actually find the invitation
830
+
831
+ The duplicate-invitation guard added with the existing-provider invite fix never
832
+ fired. Invitations are written with `metadata.siteId` as an ObjectId, but the
833
+ lookup searched for the site id as the plain string it arrived as on the request,
834
+ and MongoDB does not treat the two as equal. The query therefore always came back
835
+ empty: a property manager could send the same service provider a second, third
836
+ and fourth invitation for the same site while the first was still valid, and each
837
+ one produced another email and another pending row for them to chase.
838
+
839
+ The lookup now matches either form, so older records that hold the string are
840
+ still found. Nothing else changes: the refusal message and the status code are
841
+ the ones already shipped.
842
+
843
+ - d6c92d7: Stop rejecting site names that only differ by their building number
844
+
845
+ Adding a site refused any name within a Levenshtein distance of 2 of an
846
+ existing site in the same organisation, and told the user to contact support.
847
+ That is exactly one character, so "Winsland House II" could not be added next
848
+ to "Winsland House I". The same rule blocked "Tower A" beside "Tower B",
849
+ "Phase 2" beside "Phase 1" and "Block 15" beside "Block 5" — the standard way
850
+ buildings are named here.
851
+
852
+ The check now treats trailing numbers, Roman numerals and single letters as the
853
+ part that tells two buildings apart: if they differ, the names are different
854
+ sites and the distance is never measured. Real duplicates are still refused —
855
+ an exact repeat, a different capitalisation, stray or doubled whitespace,
856
+ punctuation-only differences, and a one or two character typo within the same
857
+ building. "House 1" and "House I" are still read as the same building.
858
+
859
+ The refusal now names the site it matched and says what to do about it instead
860
+ of pointing the user at support.
861
+
862
+ `site.repo.getByExactName` also built its case-insensitive regex from the raw
863
+ name; a name containing regex characters either threw or matched a site it is
864
+ not. It is escaped.
865
+
866
+ - b803f36: Staff console authorization: subscriptions, plans, promo codes, client status and platform Terms now require a Seven365 staff membership
867
+
868
+ The organisation, subscription, subscription-plan, promo-code and platform-terms
869
+ controllers checked only that a session existed, so every write behind the
870
+ Seven365 staff console was reachable by any signed-in account on the platform.
871
+
872
+ - `requirePlatformStaff` (new `src/utils/console-authz.util.ts`) gates the writes
873
+ that are ours to make: creating an organisation, suspending or reactivating a
874
+ client, the plan catalogue, minting a promo code, and publishing platform-wide
875
+ Terms. It reuses `isSuperAdmin` — a `members` row of type "admin" whose role is
876
+ also of type "admin" — the same check SP Approvals, Marketplace Vendors and the
877
+ notification broadcast already use. No role-name string takes part.
878
+ - `requireOrgAccess` gates the two writes a client legitimately makes on their
879
+ own account — editing their organisation during onboarding, and changing the
880
+ seat count on their own subscription. The organisation is read off the stored
881
+ subscription, never off the request.
882
+ - `POST /terms` took `createdBy` from the request body; it is now the session, so
883
+ published Terms can no longer be attributed to somebody else. The `user` field
884
+ is still accepted and ignored, so no application has to be released in step.
885
+ - The two `organizations/v2` reads page over every organisation on the platform
886
+ with its subscription and billing data; they are gated to staff for the same
887
+ reason.
888
+
889
+ The paid signup checkout and self-signup organisation creation are deliberately
890
+ left open — they are customer paths, and a test pins them that way.
891
+
892
+ - 2e0b41a: Terms acceptance: gate the per-user status and accept handlers to the person themselves
893
+
3
894
  ## 3.46.0
4
895
 
5
896
  ### Minor Changes