@7365admin1/core 3.77.0 → 3.77.1-staging.330

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 (61) hide show
  1. package/.changeset/cctv-live-view-signed.md +12 -0
  2. package/.changeset/cctv-recorded-footage.md +5 -0
  3. package/.changeset/client-invite-owner-role-permissions.md +5 -0
  4. package/.changeset/client-invite-service365.md +5 -0
  5. package/.changeset/customer-sites-one-row-per-site.md +19 -0
  6. package/.changeset/daily-occurrence-book-startup.md +34 -0
  7. package/.changeset/hid-duplicate-enrollment.md +22 -0
  8. package/.changeset/hid-facial-error-wording.md +20 -0
  9. package/.changeset/hid-identity-user-link.md +26 -0
  10. package/.changeset/hid-objectid-across-driver-copies.md +26 -0
  11. package/.changeset/hid-profile-qr-backend.md +31 -0
  12. package/.changeset/hid-reader-user-search.md +22 -0
  13. package/.changeset/inventory-notification-category.md +5 -0
  14. package/.changeset/manpower-authenticated-namespace.md +27 -0
  15. package/.changeset/member-direct-role-org.md +5 -0
  16. package/.changeset/member-direct-self-enrol-role.md +5 -0
  17. package/.changeset/member-list-site-scope.md +5 -0
  18. package/.changeset/member-lookup-follows-site.md +16 -0
  19. package/.changeset/members-by-user-reach.md +5 -0
  20. package/.changeset/module-ceiling-site-owner.md +5 -0
  21. package/.changeset/module-gate-foundation.md +20 -0
  22. package/.changeset/module-gate-kill-switch-and-read.md +26 -0
  23. package/.changeset/module-gate-server.md +35 -0
  24. package/.changeset/notify-access-filter-on.md +24 -0
  25. package/.changeset/org-owner-membership-repair.md +15 -0
  26. package/.changeset/org-read-reach.md +5 -0
  27. package/.changeset/org-update-diagnosable-error.md +14 -0
  28. package/.changeset/patrol-email-scope-restore.md +16 -0
  29. package/.changeset/people-create-reach-enforce.md +5 -0
  30. package/.changeset/people-create-reach-gate.md +31 -0
  31. package/.changeset/people-occupied-structure.md +5 -0
  32. package/.changeset/real-estate-developer-app-type.md +5 -0
  33. package/.changeset/real-estate-developer-lookup-both-types.md +5 -0
  34. package/.changeset/resident-app-module-ceiling.md +18 -0
  35. package/.changeset/role-module-ceiling.md +16 -0
  36. package/.changeset/role-read-self-arm.md +5 -0
  37. package/.changeset/role-v2-platform-grant.md +14 -0
  38. package/.changeset/site-module-allowlist.md +5 -0
  39. package/.changeset/site-modules-client-admin.md +5 -0
  40. package/.changeset/site-save-resident-ceiling.md +7 -0
  41. package/.changeset/user-email-allowlist-subscription-authz.md +21 -0
  42. package/.changeset/user-field-self-allowlist.md +5 -0
  43. package/.changeset/user-invite-dedupe.md +5 -0
  44. package/dist/index.d.ts +338 -55
  45. package/dist/index.js +7269 -5436
  46. package/dist/index.js.map +1 -1
  47. package/dist/index.mjs +10381 -8551
  48. package/dist/index.mjs.map +1 -1
  49. package/package.json +68 -68
  50. package/scripts/hid-backfill-enrolment-access.mjs +198 -0
  51. package/test/document-expiry-event.test.mjs +146 -0
  52. package/test/document-expiry-filter.test.mjs +167 -0
  53. package/test/document-expiry-reminder.test.mjs +161 -0
  54. package/test/document-type-resolution.test.mjs +62 -0
  55. package/test/e2e/document-expiry-event-link.e2e.test.mjs +323 -0
  56. package/test/guard-invite-wiring.test.mjs +48 -0
  57. package/test/hid-enrolment-grants-access.test.mjs +162 -0
  58. package/test/notification-category.util.test.mjs +21 -0
  59. package/test/occupancy-tree.test.mjs +128 -0
  60. package/test/occurrence-book-create.util.test.mjs +371 -0
  61. package/test/resident-login-account.test.mjs +126 -0
@@ -0,0 +1,12 @@
1
+ ---
2
+ "@7365admin1/core": minor
3
+ ---
4
+
5
+ Sign the camera relay's LIVE view link, the same way recorded footage is already signed
6
+
7
+ The relay's live routes (player page, stream socket, screenshot, clip) had no authorization at
8
+ all. The camera payloads this server returns now carry a signed `liveUrl` and a reusable
9
+ `liveAuth` (`exp=&sig=`), HMAC-SHA256 over `channel|exp` with the same PLAYBACK_SIGNING_KEY and
10
+ the same 120-second lifetime as a playback link. The stored `camera.host` is deliberately left
11
+ untouched: a query string there would switch off the liveVideo capability and would be frozen
12
+ into patrol-route documents. A camera whose relay is not configured is unchanged.
@@ -0,0 +1,5 @@
1
+ ---
2
+ "@7365admin1/core": minor
3
+ ---
4
+
5
+ Recorded footage for CCTV cameras: `RELAY_PLAYBACK` capability (`CAMERA_PLAYBACK_RELAYS` + referenced signing key), `getPlayback` signs a 120-second link to the camera relay's recorder playback for a past window of up to 30 minutes, gated by site reach and the new `cctv-mgmt:view-recordings` permission.
@@ -0,0 +1,5 @@
1
+ ---
2
+ "@7365admin1/core": minor
3
+ ---
4
+
5
+ Invite Client: Seven365 staff choose the new client owner's role permissions with the modules (`permissions` on `POST /api/auth/invite/user`, only with `modules`, each inside them, never a console permission). Onboarding writes them on the client's own application owner role and records it as the organisation's `invitedRole`, so the owner's application membership gets exactly that role. Role writes for that application in that organisation stay within it (`*`/empty becomes the grant; wider strings are refused; Seven365 staff are not capped). Clients invited before this are unchanged. Kill switch: `CLIENT_INVITE_OWNER_PERMISSIONS=off`.
@@ -0,0 +1,5 @@
1
+ ---
2
+ "@7365admin1/core": minor
3
+ ---
4
+
5
+ Invite Client for a Property Management or real estate developer client can carry the Service365 resident app modules; onboarding writes them on the organisation
@@ -0,0 +1,19 @@
1
+ ---
2
+ "@7365admin1/core": patch
3
+ ---
4
+
5
+ Return one row per site from `/api/customer-sites`, named by the site.
6
+
7
+ `getAll` grouped by `{ name, site }`. A customer-sites row is an ENGAGEMENT and
8
+ one site can carry more than one, so two rows for the same site with different
9
+ names both survived as "distinct" — a provider's site list showed four entries
10
+ for three sites, and the site switcher ticked two at once.
11
+
12
+ `name` is also a snapshot from when the engagement was created and goes stale
13
+ when the site is renamed; recency is no guide, since the newer row held the
14
+ staler name. The site is now looked up and its live name projected over the
15
+ snapshot, with the stored name kept as a fallback.
16
+
17
+ The search and the sort both run after the rename, so a site is found and
18
+ ordered by the name it actually has, and the count is derived from the same
19
+ pipeline.
@@ -0,0 +1,34 @@
1
+ ---
2
+ "@7365admin1/core": minor
3
+ ---
4
+
5
+ Create the daily occurrence book on server startup as well as at midnight, and skip a site that already has today's book
6
+
7
+ `processCreateDOB` was reachable only from the `0 0 * * *` cron API-core registers. A cron pattern
8
+ does not fire when it is registered, so a server that was down, redeployed, or running outside
9
+ production at midnight skipped that day and nothing ever went back for it - one site's book list
10
+ stopped at 17/07/2026 while its guards kept logging entries.
11
+
12
+ The job is now idempotent by the Singapore day: it reads the site ids that already have a book dated
13
+ inside today's window (`getSiteIdsWithBookInWindow`) and inserts only for the rest, returning
14
+ `{ created, skipped, message }`. The unique `{ site, date }` index cannot stand in for that check,
15
+ because the stored `date` carries the hour of the run, so 00:00 and a 09:15 restart are two different
16
+ keys. The book counter is consumed only when there is something to insert. API-core can therefore
17
+ call it on boot as well as on the cron.
18
+
19
+ `closeOccurrenceBooks` no longer throws when nothing is due to be closed - the normal case on a quiet
20
+ night, and now on every restart - and logs instead.
21
+
22
+ Every run now logs its outcome from inside the service - created how many and skipped how many, or nothing to create, and how many books were closed - so the midnight cron reports the same way the startup pass does. Silence used to be the only evidence either way.
23
+
24
+ The book number is now PER SITE. It came from one global `occurrence-book` counter row bumped once per run, which held only while every site got a book on the same night: a site added mid-day, or one that later stops taking daily occurrence books, would have its next number jump by however many days it missed. `getHighestEntryCounterBySite` reads each site's own high-water mark (deleted books included, so an issued number is never handed out twice) and `nextEntryCounter` adds one, so a site with no books starts at No. 1. Existing sites are unaffected: their highest number today is the same value the shared counter last wrote, so the sequence continues where it left off. Manual creation through `POST /occurrence-books` is numbered the same way, and the counter row no longer decides any number.
25
+
26
+ A new book's `date` is now Singapore midnight of the day it is for, not the instant the run happened, so every run of a day writes the same `{ site, date }` key and the unique index can refuse a second book. While `date` carried the hour, the 00:00 cron wrote 16:00Z and a 09:04 restart wrote 01:00Z - two different keys - and staging ended up with two books dated 21/09/2026. `closedAt` follows the same midnight, so a book created by a restart no longer closes hours later than the same day's cron book would have. A duplicate-key refusal is reported and logged rather than thrown, since it means another instance created today's book between this run's check and its write.
27
+
28
+ Every book now carries `day`, its calendar day as `YYYY-MM-DD`, and "one book per site per day" is enforced by a unique partial index `unique_site_day` on `{ site, day }` rather than by each caller. `ensureOneBookPerSiteDay`, run by API-core as the server starts and in each timezone's midnight job, backfills `day` onto any book without one, deletes same-day duplicates that are provably empty (no entries, `totalInput` 0 - deleted rather than soft-deleted because the book list does not filter `deletedAt`; the book holding entries is always kept, and a group where more than one book holds entries is reported and left alone), and builds the index. A book written by an out-of-date server has no `day`; setting it collides with the index, and that collision is caught and settled as a duplicate, so such a book is gone by the next midnight job or restart. No collection validator is used: it would need the `collMod` privilege, which the application user does not hold. A duplicate refusal (11000) in the cron or start-up path is logged, not thrown; a manual `POST /occurrence-books` for a day that already has a book answers 400 with the day named. The integrity run logs at info only when it changed something, and at warn when something needs a person.
29
+
30
+ A book now belongs to its SITE's local day. `siteBookDay` reads `sites.timezone` (Singapore when a site has none or an invalid one - every site today, so nothing moves until a site is given another zone) and gives the site-local `day`, local midnight as `date`, and `closedAt` 72 hours later, all from an absolute instant and an explicit zone, so the result does not depend on where the server runs or what its clock zone is. `getAllSitesUnpaginated` now also returns `timezone`. `processCreateDOB` groups sites by zone - one existence query per zone - and creates only for sites whose own day has started; `getBookTimezones` returns the zones the sites actually use (`bookTimezones` over a `distinct` of `sites.timezone`; Singapore for sites with none, so today it is just `Asia/Singapore`), and API-core schedules one `0 0 * * *` job per zone, in that zone, instead of a single 00:00 that is midnight for one zone only. A run that creates or closes nothing logs at debug and no longer flushes the occurrence-book cache. The close cut-off is 72 hours of plain milliseconds rather than `setDate(getDate() - 3)`, which followed the server's local calendar. Books written before this change are backfilled with their Singapore day, the schedule they were created on.
31
+
32
+ A newly created site gets today's book immediately instead of waiting for its next local midnight or a restart. The three site-creation paths (`site`, `customer` and `customer-site` services) call `startTodaysBookForSite` right after their transaction commits, so a rolled-back site never gets an orphan book. It is fire-and-forget and never throws, not even synchronously, so a book problem can never make an already-committed site look like it failed. It runs through `processCreateDOB({ siteIds })`, so the book gets the site's own timezone, day and No. 1, with the same duplicate protection as the midnight job.
33
+
34
+ `startTodaysBookForSite` is also called after a site's timezone is edited (`PUT /api/sites/:id` with `timezone`), and it tells API-core the set of timezones may have changed through `onBookTimezonesChanged`, a listener API-core registers at start-up. So a zone no site used before gets its midnight job without any polling, and a site moved into a zone whose day has already begun gets that day's book. A server that misses the call re-reads the timezones on its next restart.
@@ -0,0 +1,22 @@
1
+ ---
2
+ "@7365admin1/core": minor
3
+ ---
4
+
5
+ Refuse to enrol the same person on one HID reader twice.
6
+
7
+ `POST /readers/:readerId/identities` and `PATCH /identities/:identityId` now
8
+ check whether the linked resident, staff member or service provider already
9
+ holds a HID user on that reader, and refuse with a 400 naming the user that is
10
+ in the way. The existing duplicate check in `addIdentity` could not catch this:
11
+ it asks about the reader's own keys, and enrolment allocates a fresh
12
+ `hidUserId` and registration every time, so a second enrolment of one person
13
+ looked entirely new while leaving the reader holding two users for one human.
14
+
15
+ The check is reader-scoped on purpose — the same person enrolled at a different
16
+ gate is normal. Visitors are excluded, because their identity is re-issued
17
+ rather than duplicated and a returning visitor legitimately gets a new
18
+ credential on the same reader.
19
+
20
+ `GET /readers/:readerId/identities` takes an optional `person` filter so the
21
+ enrolment form can ask the same question before it writes anything to the
22
+ device.
@@ -0,0 +1,20 @@
1
+ ---
2
+ "@7365admin1/core": patch
3
+ ---
4
+
5
+ Say what a rejected face photo actually means.
6
+
7
+ A facial failure was passed through as the reader wrote it, so an operator was
8
+ shown `HID facial enrollment failed - code 3: Face exists Scores:
9
+ {"bounds_width":240,...}`. The code is the device's, the scores are its quality
10
+ figures for the photo, and neither says what went wrong or what to do.
11
+
12
+ The codes we have seen the reader return are now written as sentences: a face
13
+ already enrolled under another HID user, a face too close to the camera, a face
14
+ not square to it, a face too near the photo's edge. The device's own codes are
15
+ quoted once at the end so support still has them, and a code we have not seen is
16
+ passed through verbatim rather than guessed at.
17
+
18
+ The raw errors and quality scores move off the message and onto the
19
+ `hid-amico-events` row, which is where a rejected photo gets diagnosed later.
20
+ They were the least useful half of a modal and the most useful half of a log.
@@ -0,0 +1,26 @@
1
+ ---
2
+ "@7365admin1/core": minor
3
+ ---
4
+
5
+ Store the user account on a HID identity, and recognise one person across every
6
+ id they are filed under.
7
+
8
+ An identity's link fields name a RECORD, and different screens name the same
9
+ human through different records: permission reconciliation writes `user` and
10
+ `member`, the enrolment screen writes `person`, and the enrolment picker itself
11
+ has offered two different things over time, since `loadPermissionSubjects`
12
+ returns a `site.people` id for a resident with an account and a `users` id for
13
+ one without. The duplicate check compared field to matching field, so it saw
14
+ none of it. One reader was found holding three HID users for one resident, keyed
15
+ `user`, `person`-as-user-id and `person`-as-people-id.
16
+
17
+ `createIdentity` and `updateIdentity` now resolve the subject's account through
18
+ `resolvePermissionUserBindings`, the same resolver the permissions screen uses,
19
+ and store it as `user` alongside the record link. The duplicate check matches
20
+ every id that names the subject against every field that can hold one, so a
21
+ resident already enrolled through any screen is refused rather than duplicated.
22
+
23
+ `GET /readers/:readerId/identities` gains a `subject` filter that asks the same
24
+ question, so the enrolment screen can warn before it writes anything to the
25
+ device instead of failing at submit. The existing `person` filter is unchanged
26
+ and remains an exact match on that one field.
@@ -0,0 +1,26 @@
1
+ ---
2
+ "@7365admin1/core": patch
3
+ ---
4
+
5
+ Stop the HID repository mistaking every id it reads from Mongo for "not an id".
6
+
7
+ `optionalObjectId` in `hid-amico.repo.ts` identified ObjectIds with
8
+ `instanceof`. That only ever holds for an id this file minted itself: three
9
+ copies of the `mongodb` package are installed across `iservice365-core`,
10
+ `iservice365-utils` and `iservice365-API-core`, and `useAtlas` opens its
11
+ connection with the one under `iservice365-utils`, so every id on every
12
+ document it returns belongs to a different class. `instanceof` was false and
13
+ the value fell through to a `typeof value !== "string"` branch that returned
14
+ null.
15
+
16
+ The visible effect was enrolment. `assertPermissionSubject` treats a resident
17
+ as valid only when their `site.people` record carries a linked user account,
18
+ and it read that link through this helper — so a resident with an account read
19
+ as a resident without one, and `POST /readers/:readerId/identities` answered
20
+ 400 "The linked HID identity subject is not active at this site." for residents
21
+ who were perfectly enrollable.
22
+
23
+ Ids are now read structurally, by `toHexString`, which every copy's ObjectId
24
+ has. `toValidatedId` goes through the same helper, so it too stops rejecting
25
+ ids that came from the database. `hid-amico.model.ts` already read ids this
26
+ way; this brings the repository in line with it.
@@ -0,0 +1,31 @@
1
+ ---
2
+ "@7365admin1/core": minor
3
+ ---
4
+
5
+ Let a signed-in member hold their own HID QR code.
6
+
7
+ `GET`, `POST` and `DELETE /readers/:readerId/profile-qr` read, issue and revoke
8
+ the caller's own QR credential on one reader. The caller comes from the session,
9
+ so there is no user id in the path to point at somebody else, and each handler
10
+ calls `authorizeReader` for itself rather than trusting the router - the site is
11
+ re-resolved from the stored reader record.
12
+
13
+ The credential is the visitor mechanism: an alphanumeric value in the reader's
14
+ `qrcodes` table, with the reader put into the QR-readable configuration first.
15
+ Unlike a visitor pass it carries no validity window, matching the member's
16
+ facial enrollment, which is standing until removed. Issuing replaces any earlier
17
+ code for that member on that reader, and the replacement is written and verified
18
+ before the old one is removed.
19
+
20
+ It creates no access rule, deliberately. `setProfileFacial` beside it does not
21
+ either: a member's door access comes from the HID permissions screen, and
22
+ minting a rule here would mean anyone who can press Generate admits themselves.
23
+ The read reports whether that screen admits them, resolved through
24
+ `resolvePermissionUserBindings` - the same resolver that writes the rules onto
25
+ the device - so the answer is the one the reader will actually be asked.
26
+
27
+ Two settings are now readable that were already writable: the QR identification
28
+ and legacy-mode flags `configureVisitorQrReader` sets on every issue, plus
29
+ `access_rule_time_zones` alongside its two sibling link tables. Whether a reader
30
+ was set up to accept a QR, and whether an access rule had a schedule, were
31
+ invisible to every screen and every diagnosis.
@@ -0,0 +1,22 @@
1
+ ---
2
+ "@7365admin1/core": patch
3
+ ---
4
+
5
+ Make the search on the HID reader user roster work.
6
+
7
+ `listReaderUsers` asked the device to filter. Amico's `where` takes a value or a
8
+ list of values per column and has no operator form, so the substring clause it
9
+ sent for a text term, `{name: {LIKE: "%x%"}}`, was answered
10
+ `400 Invalid value (string or array expected)`. Every search by name threw, and
11
+ the screen showed "Unable to load HID users from reader" with an empty table.
12
+
13
+ A numeric term took a different branch that matched the HID user id exactly.
14
+ That did not fail, it answered wrongly: searching a registration number like
15
+ `000004` resolved to `id = 4` and returned a different person.
16
+
17
+ Rows are already pulled in one page and filtered here for `userType`, so the
18
+ search now joins that filter as a case-insensitive substring over name, id and
19
+ registration - the same three fields, and the same "contains" semantics, the
20
+ identities search uses. Verified against a live reader: a name matches
21
+ partially and case-insensitively, a registration number returns its own user,
22
+ a full UID still works, and no match returns an empty page rather than an error.
@@ -0,0 +1,5 @@
1
+ ---
2
+ "@7365admin1/core": minor
3
+ ---
4
+
5
+ Add the `inventory` notification category (in-app and push), offered to roles holding any `inventory:*` permission, for API-core's inventory alerts: a request waiting for approval, a decided request, and low stock.
@@ -0,0 +1,27 @@
1
+ ---
2
+ "@7365admin1/core": minor
3
+ ---
4
+
5
+ Manpower live status: an authenticated namespace beside the open one
6
+
7
+ `/manpower` streams every client site's live staffing level — how many guards are
8
+ actually on shift against how many were promised — to anyone who connects, with no
9
+ credential of any kind.
10
+
11
+ This adds `/manpower-live`, carrying exactly the same `manpower-status-update`
12
+ events, which admits a socket only when the handshake presents a session id that
13
+ resolves to a live session. Every refusal is an identical `Unauthorized`, and a
14
+ session store that is down refuses rather than letting everyone in.
15
+
16
+ `/manpower` itself is deliberately unchanged: the Capacitor guard app installed on
17
+ phones today and the Nuxt security screen both connect to it with no credential and
18
+ would be cut off the moment it were tightened. Every update is emitted to both, so
19
+ the two feeds can never disagree, and both namespaces share one connection handler,
20
+ so a client on the authenticated feed still starts the poll and still gets an
21
+ immediate snapshot.
22
+
23
+ A live session is the whole check, deliberately. Scoping the feed to the caller's
24
+ own sites is not possible today: manpower lives in HRM Labs' vendor id space, none
25
+ of those site ids match ours, and no field links the two — a site check would empty
26
+ the screen for the one customer using it. That mapping is a schema change and
27
+ belongs to the leads.
@@ -0,0 +1,5 @@
1
+ ---
2
+ "@7365admin1/core": patch
3
+ ---
4
+
5
+ POST /api/members/direct refuses (400) a role that belongs to a different organisation, unless the caller already holds that role in the organisation being joined.
@@ -0,0 +1,5 @@
1
+ ---
2
+ "@7365admin1/core": patch
3
+ ---
4
+
5
+ POST /api/members/direct: adding yourself now needs a role you may give yourself - Seven365 staff, the organisation's owner, a role you already hold there, or the role your own invitation offers. Enrolling somebody else is unchanged.
@@ -0,0 +1,5 @@
1
+ ---
2
+ "@7365admin1/core": minor
3
+ ---
4
+
5
+ `GET /api/members?org=` lists only the sites a caller is pinned to when the caller holds no organisation-wide membership or organisation-level role there; a `siteId` outside those sites is refused. Organisation-wide members, owners/invitees and Seven365 staff are unchanged; a resident-only caller lists no staff.
@@ -0,0 +1,16 @@
1
+ ---
2
+ "@7365admin1/core": patch
3
+ ---
4
+
5
+ A person's role now follows the site they are on.
6
+
7
+ `GET /api/members/user/:id/app/:type` accepts an optional `?site=` (a 24-hex
8
+ site id, like `?org=`). When it is sent, the answer is the person's live
9
+ membership of that app type pinned to that site (inside `?org=` when it is also
10
+ sent); failing that, their organisation-wide membership in `?org=`; failing
11
+ that, any live membership in `?org=`. When none of these exists the route
12
+ answers exactly as before. A removed membership never wins, and a site never
13
+ turns a found membership into "not found". Callers that do not send `site`
14
+ see no change. Login, auth-v2 and verification are unchanged.
15
+
16
+ The rule is the pure `pickSiteMembership` in `utils/member-lookup.util.ts`.
@@ -0,0 +1,5 @@
1
+ ---
2
+ "@7365admin1/core": patch
3
+ ---
4
+
5
+ `GET /api/members/users/:id` no longer shows anybody's memberships to any signed-in account. The person and Seven365 staff still get the full list; anyone else gets only the memberships in organisations they belong to, were invited to or own (an empty list, not an error, when there are none).
@@ -0,0 +1,5 @@
1
+ ---
2
+ "@7365admin1/core": patch
3
+ ---
4
+
5
+ Role writes: a site's own module list narrows a role only when the site belongs to the role's organisation. A service provider's role at a client's site is no longer refused modules the client switched off for that site — matching what the role editor now offers.
@@ -0,0 +1,20 @@
1
+ ---
2
+ "@7365admin1/core": patch
3
+ ---
4
+
5
+ Lay the groundwork for module lists becoming a gate, without narrowing anyone.
6
+
7
+ - New `utils/module-gate.util.ts`: the list of modules a client's list can
8
+ govern (generated from layer-common 5a96f95), the spelling groups, the
9
+ always-included set, and pure functions that work out which modules a
10
+ caller's apps must hide (`deniedModules`) and who would lose what if a list
11
+ changed (`moduleImpact`). Nothing calls them yet.
12
+ - The organisation and site module checks now treat every spelling of a module
13
+ as the same module (ticking `workOrder` also allows `work_orders`,
14
+ `work_order` and `work-orders`), and never refuse members, invitations, roles,
15
+ sites, customers, subscriptions, payment methods or site settings. This only
16
+ ever refuses less than before.
17
+ - A Site Settings save (`PATCH /api/sites/id/:id`) now always keeps the site's
18
+ stored module list. Before this, the save replaced `metadata` wholesale and
19
+ could wipe or overwrite that list. `PATCH /api/sites/:id/modules` is the only
20
+ way to change it.
@@ -0,0 +1,26 @@
1
+ ---
2
+ "@7365admin1/core": patch
3
+ ---
4
+
5
+ Module gate: the kill switch now stops the 409, and the check stops reading when there is nothing to decide
6
+
7
+ Two follow-ups from the module-gate server review, both behaviour-neutral for
8
+ anybody who is not using the kill switch.
9
+
10
+ **`MODULE_LIST_GATE=off` now means off.** Every other part of the gate checked the
11
+ master switch; `mustAcknowledge` did not. So with the gate switched off, a save that
12
+ narrowed a client's module list was still refused with a 409 "acknowledge the impact
13
+ first" — the one lever a lead would reach for in an emergency, not working for the
14
+ one path that blocks a save from going through. Both the organisation and the site
15
+ save paths go through that function, so both are fixed. With the switch at its
16
+ default the 409 behaves exactly as before, and that is asserted.
17
+
18
+ **`moduleDeniedForCaller` no longer reads memberships when no list is enforced.** It
19
+ runs on every governed request and read the caller's memberships before finding out
20
+ whether there was anything to decide — and `members.user` has no index to use. It now
21
+ returns early unless the site or its organisation has an enforced list, which is what
22
+ the notification filter already did. No verdict changes: a client that really has an
23
+ enforced list is still measured exactly as before, and that is asserted too.
24
+
25
+ Also corrects the 3.65.0 changelog, which claimed `MODULE_LIST_GATE` switched all of
26
+ the gate off at once. It did not, for the reason above.
@@ -0,0 +1,35 @@
1
+ ---
2
+ "@7365admin1/core": minor
3
+ ---
4
+
5
+ Module gate, server step: the deny-list endpoint, the impact preview, and
6
+ acknowledge-before-save. Everything ships inert and fails open.
7
+
8
+ - `useMemberController().getModulesByUserIdType` (for
9
+ `GET /api/members/user/:id/app/:type/modules?org=&site=`) answers
10
+ `{ denied: string[] }` for the membership `getByUserIdType` answers with. It
11
+ is the same self-or-staff guard. It answers `[]` for no list, an empty list,
12
+ a resident, Seven365 staff, a site of another organisation, any lookup
13
+ error, and `MODULE_LIST_GATE=off`. `ALWAYS_ALLOWED` is never denied.
14
+ - A saved list is enforced only once it has been saved through the
15
+ acknowledged preview. The marker is the save's own append-only console-audit
16
+ row (`gateListHash`). A list saved earlier, or changed any other way, behaves
17
+ as absent. No field is added to any organisation or site.
18
+ - `useOrgController().previewOrgModules` and
19
+ `useSiteController().previewSiteModules` (for
20
+ `POST /api/organizations/:id/modules/preview` and
21
+ `POST /api/sites/:id/modules/preview`) return who would lose what. They are
22
+ read-only and never go through `GET /api/roles`.
23
+ - `updateModules` and `updateSiteModules` accept `acknowledgeImpact`. A save
24
+ that takes a module away from somebody is refused with 409 until it is
25
+ acknowledged. An empty list restores everything and never needs it. Site
26
+ saves are now recorded in the console audit (`site.modules-changed`).
27
+ - `filterByAccess` (Q7) runs at the top of the notification `send()`. It is
28
+ `NOTIFY_ACCESS_FILTER=log` by default: it logs who would be filtered and
29
+ still sends to everyone. It is exported so API-core's own sender can call it.
30
+ - `moduleDeniedForCaller` (Q3) is the server-refusal primitive. It is
31
+ `MODULE_GATE_MODE=log` by default and never refuses. `enforce` refuses only
32
+ the keys in `MODULE_GATE_ENFORCE_KEYS`. Work stamped (`at`) before the
33
+ acknowledged save is accepted.
34
+ - `MODULE_LIST_GATE` (code default `on`) switches all of the above off at
35
+ once.
@@ -0,0 +1,24 @@
1
+ ---
2
+ "@7365admin1/core": minor
3
+ ---
4
+
5
+ Notifications: stop sending alerts for modules a client was never given
6
+
7
+ `NOTIFY_ACCESS_FILTER` now defaults to `on` instead of `log`, so the module-based
8
+ drop is real: a recipient no longer gets a notification when every membership they
9
+ hold at the site denies every module the notification's category is about.
10
+
11
+ Only the module drop acts. The role-based check is still computed and logged and
12
+ never drops anybody, because most apps do not enforce their role gates yet and a
13
+ role-based drop would hide alerts for screens people can still open.
14
+
15
+ Unchanged: residents, anyone with no membership at the site, Seven365 staff, sends
16
+ with no site, and categories the catalogue does not govern are all exempt; any
17
+ error sends to everyone; and `MODULE_LIST_GATE=off` still disables it entirely.
18
+ `NOTIFY_ACCESS_FILTER=log` or `off` overrides this default with no deploy.
19
+
20
+ This is inert until a client's module list is enforced — measured read-only on
21
+ staging at the time of the flip, no organisation (0 of 199) and no site (0 of 256)
22
+ had a stored list and there were no acknowledged-save markers, so nothing is
23
+ dropped for anybody until a Super Admin saves a module list with the acknowledged
24
+ preview.
@@ -0,0 +1,15 @@
1
+ ---
2
+ "@7365admin1/core": minor
3
+ ---
4
+
5
+ Add a staff-only handler that repairs an organisation's missing owner membership.
6
+
7
+ An organisation created before 3.60.0 can exist with no `members` row at all, so
8
+ `GET /api/members/user/:id/app/organization` answers 404 and every console
9
+ landing page tells the real owner that no organisation exists. The new
10
+ `orgOwnerRepairHandlers` writes the one membership the onboarding path already
11
+ writes, for one organisation at a time, and only when that organisation is
12
+ active, holds exactly zero `organization` memberships, and has an account
13
+ registered under its own e-mail address. Staff-only (`organizations` console
14
+ permission), idempotent under a one-writer claim, audited, and behind the
15
+ `ORG_OWNER_REPAIR` switch. No schema change and no migration.
@@ -0,0 +1,5 @@
1
+ ---
2
+ "@7365admin1/core": patch
3
+ ---
4
+
5
+ GET /api/organizations/id/:id returns the whole organisation record only to a caller with a relationship to it (member, invitee, owner, Seven365 staff, an active resident of one of its sites, or an engaged service provider). Everyone else gets the name-only record the anonymous lookup already returns. It never answers 401/403.
@@ -0,0 +1,14 @@
1
+ ---
2
+ "@7365admin1/core": patch
3
+ ---
4
+
5
+ Say what is wrong when an organisation cannot be saved.
6
+
7
+ `PUT /api/organizations/:id` — the write behind onboarding step 1 and the staff
8
+ Client List edit — collapsed every MongoDB write error into a 500 reading
9
+ "Failed to update organization.", with only `error.message` in the log. A
10
+ duplicate-key refusal (the `organizations` collection still carries a unique
11
+ index an earlier version of this file created and nothing ever dropped) is now
12
+ a 400 naming the field the caller has to change, a typed error raised
13
+ underneath is no longer turned into a 500, and the log line carries the error
14
+ name, code, key and the organisation id.
@@ -0,0 +1,16 @@
1
+ ---
2
+ "@7365admin1/core": patch
3
+ ---
4
+
5
+ Restore and extend the site scoping on the patrol-log email endpoints.
6
+
7
+ #1967 added a `requireSiteReach` guard to `POST /api/patrol-logs/email`. #1981,
8
+ branched before #1967 landed, rewrote the same controller to add the history
9
+ endpoints and dropped the guard. Neither repo runs its tests in CI, so the e2e
10
+ test #1967 shipped has been failing on `main` unnoticed ever since.
11
+
12
+ All five endpoints now resolve the caller from the session: the two that name a
13
+ site (`send`, `getAll`) check that site, and the three that name a record
14
+ (`resend`, `getById`, `deleteById`) check the site stored on the record. The
15
+ `createdBy` pinning is restored with them, so a send cannot be filed under
16
+ another user's name.
@@ -0,0 +1,5 @@
1
+ ---
2
+ "@7365admin1/core": patch
3
+ ---
4
+
5
+ Enforce the reach check on `POST /api/people` by default: a signed-in caller can only create a person at a site (or organisation) they belong to or serve. `PEOPLE_CREATE_REACH_MODE=log` switches back to counting without refusing.
@@ -0,0 +1,31 @@
1
+ ---
2
+ "@7365admin1/core": patch
3
+ ---
4
+
5
+ POST /api/people: count cross-client person creation (gate ships switched off)
6
+
7
+ The signed-in branch of `POST /api/people` checked only that somebody was signed
8
+ in. The site and organisation came straight from the request body, and where that
9
+ body carries an e-mail, a password and a resident/tenant type, the service goes on
10
+ to create a **user account** and an **active membership** as well as the person row.
11
+
12
+ `site.people` is the collection every site-reach decision on the platform reads its
13
+ verdict from, so a row written there is not an ordinary record — it manufactures
14
+ reach into another client's estate.
15
+
16
+ This adds the site-reach check the camera, HID and people-list paths already use,
17
+ behind `PEOPLE_CREATE_REACH_MODE`, and **ships it in `log` mode**: it records what
18
+ it would refuse and refuses nothing. `enforce` refuses; `off` does not look. Any
19
+ unexpected error allows the request — this must never be why a real registration
20
+ fails.
21
+
22
+ It ships off because of RP-5. A person row carries no `createdBy`, so who created
23
+ the existing rows cannot be established retrospectively and the true refusal count
24
+ is only knowable from live traffic. Measured read-only on staging: 372
25
+ `site.people` rows, of which exactly **1** is self-signup shaped (`platform:
26
+ "mobile"`, `status: "pending"`) and 371 were written by a signed-in caller, across
27
+ 3 of 256 sites.
28
+
29
+ Pre-login self-signup is untouched in every mode: a request with no session takes
30
+ the other branch of `add` before this runs, and that route is already constrained
31
+ by its own allow-list schema with `status` and `platform` pinned server-side.
@@ -0,0 +1,5 @@
1
+ ---
2
+ "@7365admin1/core": minor
3
+ ---
4
+
5
+ Add `getOccupiedStructure` to the people module: the blocks, levels and units at one site that actually have a person of a given type (`resident` and `tenant` by default), so a block/level/unit picker can offer only locations where the person step has an answer. Site-scoped and authorised with `requireSiteReach` before any person record is read; the status and type filter is `getPeopleByUnit`'s exactly, so a unit is never offered whose person list then comes back empty.
@@ -0,0 +1,5 @@
1
+ ---
2
+ "@7365admin1/core": minor
3
+ ---
4
+
5
+ Real Estate Developer clients use the Property Management application: membership and role types `real_estate_developer` are stored and looked up as `property_management_agency`, so a developer's owner can open the Property Management app and the owner grant caps their roles.
@@ -0,0 +1,5 @@
1
+ ---
2
+ "@7365admin1/core": patch
3
+ ---
4
+
5
+ Membership and role lookups by type match every stored spelling of an application: a Property Management lookup finds rows stored as `real_estate_developer` (written before 3.75.0) as well as `property_management_agency`, including the module deny list and the owner-grant cap.
@@ -0,0 +1,18 @@
1
+ ---
2
+ "@7365admin1/core": minor
3
+ ---
4
+
5
+ Add the organisation tier over the resident-app modules (R4).
6
+
7
+ `organizations.residentAppModules` is the ceiling over the per-site
8
+ `sites.metadata.residentAppModules` that property-management's Site Settings
9
+ panel already writes. `GET /api/sites/:id` now also returns
10
+ `metadata.residentAppModulesEffective`, the two tiers intersected, because the
11
+ resident app cannot read its own organisation and so cannot apply the ceiling
12
+ itself. The saved per-site field is returned unchanged alongside it, so the
13
+ settings panel still reads back what it wrote.
14
+
15
+ Only an explicit `false` disables, at either tier, so absent keeps meaning
16
+ everything and this is inert for every organisation until Seven365 sets a
17
+ ceiling. A site write that switches ON a module the client was not given is
18
+ refused by name.
@@ -0,0 +1,16 @@
1
+ ---
2
+ "@7365admin1/core": minor
3
+ ---
4
+
5
+ Enforce the organisation's module ceiling on every role write (R1 tier three).
6
+
7
+ A role can no longer be granted a permission whose module Seven365 never gave
8
+ the organisation, nor one the organisation never gave the site. Applied to all
9
+ four write paths — `role.controller` create / update / updatePermissionsById and
10
+ `role-v2.controller` create — because `/api/roles/v2` is a separate mount and
11
+ gating one leaves the rule reachable by adding `/v2` to the URL.
12
+
13
+ Only what a write ADDS is tested, so a role that predates the module list can
14
+ still be renamed and can still have a permission removed. Absent and empty
15
+ module lists still mean everything at every tier, so this is inert for all 168
16
+ live organisations until Seven365 sets a list.
@@ -0,0 +1,5 @@
1
+ ---
2
+ "@7365admin1/core": patch
3
+ ---
4
+
5
+ Scope `GET /api/roles/id/:id`: a caller may read a role they themselves hold (resolved from the session's own `members` rows), otherwise the role's organisation rule applies. Closes the cross-tenant read of any organisation's role name and permissions.
@@ -0,0 +1,14 @@
1
+ ---
2
+ "@7365admin1/core": patch
3
+ ---
4
+
5
+ Stop `/api/roles/v2` minting a role that holds the console's own resources.
6
+
7
+ `role.controller createRole` has carried `requireNoPlatformGrant` for a while;
8
+ `role-v2.controller createRole` did not. It is a separate controller behind a
9
+ separate route and accepts any permission string, so the hole was reachable by
10
+ adding `/v2` to the URL.
11
+
12
+ Measured on production before adding: no site-scoped role holds a platform-only
13
+ string, and this mount requires a `site`, so the check refuses nothing that
14
+ exists today. It only ever refuses a NEW grant regardless.