@7365admin1/core 3.52.19 → 3.53.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@7365admin1/core",
3
3
  "license": "MIT",
4
- "version": "3.52.19",
4
+ "version": "3.53.1",
5
5
  "author": "7365admin1",
6
6
  "main": "dist/index.js",
7
7
  "module": "dist/index.mjs",
@@ -13,7 +13,7 @@
13
13
  "build": "tsup src/index.ts --format cjs,esm --dts && shx cp -r src/public dist && shx cp -r src/utils/handlebars dist",
14
14
  "release": "yarn run build && changeset publish",
15
15
  "lint": "tsc",
16
- "test": "tsup src/config.ts src/utils/anpr-revoke.util.ts src/utils/vehicle-request.util.ts src/utils/camera-view.util.ts src/utils/camera-bulk-import.util.ts src/utils/dashboard-metrics.util.ts src/utils/camera-capability.util.ts src/services/camera-device-http.service.ts src/utils/camera-alert.util.ts src/utils/dahua-protocol.util.ts src/utils/site-name.util.ts src/utils/service-provider-invite.util.ts src/utils/notification-category.util.ts src/utils/hid-card.util.ts src/utils/hid-json.util.ts src/utils/hid-access.util.ts src/models/customer-site.model.ts src/models/site-camera.model.ts src/models/member.model.ts src/models/subscription.model.ts src/utils/session-id.util.ts src/utils/occurrence-query.util.ts src/utils/update-result.util.ts src/utils/hid-amico-authz.util.ts src/utils/promo-code-currency.util.ts src/utils/console-audit.util.ts src/utils/org-suspension.util.ts src/utils/session-revoke.util.ts src/models/hid-amico.model.ts src/models/patrol-email.model.ts src/utils/patrol-email-pdf.util.ts src/utils/visitor-status.util.ts src/utils/self-service-email.util.ts src/utils/hid-secret.util.ts src/models/visitor-invite.model.ts src/utils/site-timezone.util.ts src/utils/bulletin-service-apps.util.ts src/models/bulletin-board.model.ts src/utils/vehicle-import.util.ts src/utils/expired-vehicle-sweep.util.ts src/models/vehicle.model.ts src/controllers/chat.controller.ts --format esm --out-dir test/.build --no-dts --silent && node --test test/*.test.mjs",
16
+ "test": "tsup src/config.ts src/utils/anpr-revoke.util.ts src/utils/vehicle-request.util.ts src/utils/camera-view.util.ts src/utils/camera-bulk-import.util.ts src/utils/dashboard-metrics.util.ts src/utils/camera-capability.util.ts src/services/camera-device-http.service.ts src/utils/camera-alert.util.ts src/utils/dahua-protocol.util.ts src/utils/site-name.util.ts src/utils/service-provider-invite.util.ts src/utils/notification-category.util.ts src/utils/hid-card.util.ts src/utils/hid-json.util.ts src/utils/hid-access.util.ts src/models/customer-site.model.ts src/models/site-camera.model.ts src/models/member.model.ts src/models/subscription.model.ts src/utils/session-id.util.ts src/utils/occurrence-query.util.ts src/utils/update-result.util.ts src/utils/hid-amico-authz.util.ts src/utils/promo-code-currency.util.ts src/utils/console-audit.util.ts src/utils/org-suspension.util.ts src/utils/session-revoke.util.ts src/models/hid-amico.model.ts src/models/patrol-email.model.ts src/utils/patrol-email-pdf.util.ts src/utils/visitor-status.util.ts src/utils/self-service-email.util.ts src/utils/hid-secret.util.ts src/models/visitor-invite.model.ts src/models/guard-invite.model.ts src/utils/site-timezone.util.ts src/utils/bulletin-service-apps.util.ts src/models/bulletin-board.model.ts src/utils/vehicle-import.util.ts src/utils/expired-vehicle-sweep.util.ts src/models/vehicle.model.ts src/controllers/chat.controller.ts --format esm --out-dir test/.build --no-dts --silent && node --test test/*.test.mjs",
17
17
  "test:camera": "yarn test",
18
18
  "test:e2e": "yarn build && node --test --test-concurrency=1 --test-timeout=600000 \"test/e2e/*.test.mjs\""
19
19
  },
@@ -0,0 +1,251 @@
1
+ // organizations-name-index.mjs — one-off maintenance tool, run by hand, never by the app.
2
+ //
3
+ // WHY IT EXISTS
4
+ // The first version of `src/repositories/organization.repo.ts` created a UNIQUE
5
+ // index on `organizations.name` (`36150ea0`). Commit `06ee8915` stopped creating
6
+ // it, but nothing has ever DROPPED the one already built — and an index outlives
7
+ // the code that created it. While that index is still there, renaming an
8
+ // organisation to a name another organisation already uses fails with a duplicate
9
+ // key error (E11000), which is what surfaced as the onboarding 500
10
+ // "Failed to update organization."
11
+ //
12
+ // This script LOOKS for that leftover index and, only when you explicitly ask,
13
+ // drops it. It touches nothing else.
14
+ //
15
+ // HOW TO RUN IT
16
+ // From inside the `iservice365-core` folder, after `yarn install`.
17
+ //
18
+ // PowerShell (Windows) — LOOK ONLY, changes nothing:
19
+ // $env:ISERVICE365_MONGO_URI = "<the connection string>"
20
+ // node scripts/organizations-name-index.mjs
21
+ //
22
+ // PowerShell (Windows) — DROP the index (asks you to type the database name):
23
+ // $env:ISERVICE365_MONGO_URI = "<the connection string>"
24
+ // node scripts/organizations-name-index.mjs --drop
25
+ //
26
+ // bash / sh — look only, then drop:
27
+ // ISERVICE365_MONGO_URI='<the connection string>' node scripts/organizations-name-index.mjs
28
+ // ISERVICE365_MONGO_URI='<the connection string>' node scripts/organizations-name-index.mjs --drop
29
+ //
30
+ // Run it with no flag FIRST. The connection string is read from the environment
31
+ // only — there is no default, it is never printed, and it is stripped out of any
32
+ // error message this script shows.
33
+
34
+ import { createInterface } from "node:readline/promises";
35
+ import { pathToFileURL } from "node:url";
36
+ import { MongoClient } from "mongodb";
37
+
38
+ const COLLECTION = "organizations";
39
+ const ENV_VAR = "ISERVICE365_MONGO_URI";
40
+
41
+ /* ------------------------------------------------------------------ the rule
42
+ * Every index on `organizations` falls into exactly one bucket. Only "target" is
43
+ * ever dropped. Anything that touches `name` and is not recognised stops the run,
44
+ * because guessing at an index somebody else built is how you lose a collection.
45
+ */
46
+ export function classifyIndex(index) {
47
+ if (index.name === "_id_") return "protected";
48
+
49
+ const key = index.key || {};
50
+ const fields = Object.keys(key);
51
+ const isText = "_fts" in key || Object.values(key).includes("text");
52
+ if (!fields.includes("name") && !isText) return "unrelated";
53
+
54
+ if (isText) return "known-text"; // the repo's own createTextIndex()
55
+
56
+ if (fields.length === 1 && key.name === 1) {
57
+ return index.unique === true ? "target" : "known-name-plain";
58
+ }
59
+
60
+ // the repo's own createIndex() compound, which is not unique
61
+ const compound = ["name", "description", "status", "email"];
62
+ const isRepoCompound =
63
+ fields.length === compound.length && compound.every((f, i) => fields[i] === f && key[f] === 1);
64
+ if (isRepoCompound && index.unique !== true) return "known-compound";
65
+
66
+ return "unrecognised";
67
+ }
68
+
69
+ // Pure decision step: takes the raw output of listIndexes(), returns what to do.
70
+ export function planIndexDrop(indexes) {
71
+ const buckets = indexes.map((index) => ({ index, bucket: classifyIndex(index) }));
72
+ const targets = buckets.filter((b) => b.bucket === "target").map((b) => b.index);
73
+ const unrecognised = buckets.filter((b) => b.bucket === "unrecognised").map((b) => b.index);
74
+
75
+ if (unrecognised.length) {
76
+ return {
77
+ ok: false,
78
+ targets: [],
79
+ unrecognised,
80
+ reason:
81
+ `Stopping: ${unrecognised.length} index(es) on \`${COLLECTION}\` involve \`name\` in a ` +
82
+ `way this script does not recognise (${unrecognised.map((i) => i.name).join(", ")}). ` +
83
+ "Nothing has been changed.",
84
+ };
85
+ }
86
+ if (!targets.length) {
87
+ return {
88
+ ok: false,
89
+ targets: [],
90
+ unrecognised: [],
91
+ reason: `No unique index on \`${COLLECTION}.name\` exists here. There is nothing to drop.`,
92
+ };
93
+ }
94
+ return { ok: true, targets, unrecognised: [], reason: "" };
95
+ }
96
+
97
+ // Never let the connection string, or any part of it, reach the screen or a log.
98
+ export function redact(text, uri) {
99
+ let out = String(text ?? "");
100
+ if (uri) out = out.split(uri).join("<connection string hidden>");
101
+ return out.replace(/mongodb(\+srv)?:\/\/\S*/gi, "<connection string hidden>");
102
+ }
103
+
104
+ /* ------------------------------------------------------------------- helpers */
105
+
106
+ // A collection handle that can ONLY list indexes. Any other call throws here, in
107
+ // this process, before anything at all reaches the database.
108
+ function readOnly(collection) {
109
+ return new Proxy(collection, {
110
+ get(target, prop) {
111
+ if (prop !== "listIndexes") {
112
+ throw new Error(`Read-only mode: \`${String(prop)}\` is not allowed.`);
113
+ }
114
+ return target.listIndexes.bind(target);
115
+ },
116
+ });
117
+ }
118
+
119
+ function describe(index) {
120
+ const flags = [];
121
+ if (index.unique === true) flags.push("UNIQUE");
122
+ if (index.sparse === true) flags.push("sparse");
123
+ if (index.partialFilterExpression) flags.push("partial");
124
+ const suffix = flags.length ? ` [${flags.join(", ")}]` : "";
125
+ return ` ${String(index.name).padEnd(38)} key=${JSON.stringify(index.key)}${suffix}`;
126
+ }
127
+
128
+ /* ---------------------------------------------------------------------- main */
129
+
130
+ export async function main() {
131
+ const drop = process.argv.includes("--drop");
132
+ const uri = process.env[ENV_VAR];
133
+
134
+ if (!uri) {
135
+ console.error(
136
+ `\nNothing to connect to.\n\n` +
137
+ `Set the ${ENV_VAR} environment variable to the connection string of the database\n` +
138
+ `you want to look at, then run this again. There is deliberately no default — this\n` +
139
+ `script will never pick a database for you.\n`,
140
+ );
141
+ process.exitCode = 1;
142
+ return;
143
+ }
144
+
145
+ let host = "(could not be read from the connection string)";
146
+ let dbName = "";
147
+ try {
148
+ const parsed = new URL(uri);
149
+ host = parsed.host || host;
150
+ dbName = decodeURIComponent(parsed.pathname.replace(/^\//, ""));
151
+ } catch {
152
+ /* a malformed string is the driver's complaint to make, in a moment */
153
+ }
154
+ if (!dbName) {
155
+ console.error(
156
+ `\nThe ${ENV_VAR} connection string does not name a database.\n` +
157
+ `Put the database name on the end of it (for example .../iservice365) and run again.\n`,
158
+ );
159
+ process.exitCode = 1;
160
+ return;
161
+ }
162
+
163
+ console.log("\n----------------------------------------------------------");
164
+ console.log(" iService365 — organizations.name index check");
165
+ console.log("----------------------------------------------------------");
166
+ console.log(` Cluster host : ${host}`);
167
+ console.log(` Database : ${dbName}`);
168
+ console.log(` Collection : ${COLLECTION}`);
169
+ console.log(
170
+ ` Mode : ${drop ? "DROP — this run can change the database" : "LOOK ONLY — changes nothing"}`,
171
+ );
172
+ console.log("----------------------------------------------------------\n");
173
+ console.log("Check that the cluster host and database above are the ones you meant.\n");
174
+
175
+ const client = new MongoClient(uri);
176
+ try {
177
+ await client.connect();
178
+ const collection = client.db(dbName).collection(COLLECTION);
179
+ const indexes = await readOnly(collection).listIndexes().toArray();
180
+
181
+ console.log(`Indexes on \`${COLLECTION}\` right now (${indexes.length}):`);
182
+ indexes.forEach((index) => console.log(describe(index)));
183
+ console.log("");
184
+
185
+ const plan = planIndexDrop(indexes);
186
+
187
+ if (!plan.ok) {
188
+ console.log(plan.reason + "\n");
189
+ if (plan.unrecognised.length) {
190
+ console.log("What to do next: send the list above to a lead before running --drop again.\n");
191
+ process.exitCode = 1;
192
+ } else {
193
+ console.log(
194
+ "What this means: the leftover unique index is not on this database, so it is not\n" +
195
+ "the reason a rename failed here. Nothing to do.\n",
196
+ );
197
+ }
198
+ return;
199
+ }
200
+
201
+ const target = plan.targets[0];
202
+ console.log(
203
+ `FOUND IT.\n\nAn index named "${target.name}" makes \`${COLLECTION}.name\` UNIQUE, which means\n` +
204
+ "two organisations are not allowed to share a name. The application no longer wants\n" +
205
+ "that rule, and it is what makes renaming an organisation fail.\n",
206
+ );
207
+
208
+ if (!drop) {
209
+ console.log(
210
+ "Nothing has been changed — this was a look-only run.\n" +
211
+ "To remove it, run exactly the same command again with --drop on the end.\n",
212
+ );
213
+ return;
214
+ }
215
+
216
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
217
+ const typed = await rl.question(
218
+ `About to DROP the index "${target.name}" on ${host} / ${dbName}.\n` +
219
+ `Type the database name (${dbName}) to confirm, or anything else to cancel: `,
220
+ );
221
+ rl.close();
222
+ if (typed.trim() !== dbName) {
223
+ console.log("\nCancelled. Nothing was changed.\n");
224
+ process.exitCode = 1;
225
+ return;
226
+ }
227
+
228
+ console.log(`\nBefore:\n${describe(target)}\n`);
229
+ await collection.dropIndex(target.name);
230
+
231
+ const after = await readOnly(collection).listIndexes().toArray();
232
+ console.log(`After — indexes on \`${COLLECTION}\` (${after.length}):`);
233
+ after.forEach((index) => console.log(describe(index)));
234
+ console.log(
235
+ `\nDone. "${target.name}" is gone. Two organisations may now share a name, which is what\n` +
236
+ "the application expects. Ask whoever reported the rename failure to try it again —\n" +
237
+ "it should save.\n",
238
+ );
239
+ } catch (error) {
240
+ console.error("\nSomething went wrong, and nothing further was attempted:\n");
241
+ console.error(" " + redact(error?.message || error, uri) + "\n");
242
+ process.exitCode = 1;
243
+ } finally {
244
+ await client.close().catch(() => {});
245
+ }
246
+ }
247
+
248
+ // Only run when executed directly, so the test can import the logic above.
249
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
250
+ await main();
251
+ }
@@ -0,0 +1,368 @@
1
+ // End-to-end proof that a GUARD's invitation is written complete, and only for
2
+ // a site that guard can actually reach.
3
+ //
4
+ // The guard app's Invite Visitor / Invite Contractor screens called the
5
+ // RESIDENT endpoint, `POST /api/visitor-transactions/invite/:inviterId`. Two
6
+ // things were wrong with that and only the first was visible:
7
+ //
8
+ // 1. every request answered 400 - `schemaInviteVisitor` declares no `site`,
9
+ // `org`, `nric` or `members[].visitorPass`, and validates with no
10
+ // `allowUnknown`, so Joi refused all four;
11
+ // 2. worse, `site` and `org` were decoration. The resident service reads the
12
+ // inviter's `site.people` row for the estate. A guard has no such row, so
13
+ // deleting the refused keys would have turned a visible 400 into an
14
+ // invitation written with NO site and NO org - an orphan attributed to
15
+ // nobody, which nothing downstream would ever surface.
16
+ //
17
+ // So this asserts the STORED DOCUMENT, not the status code. A 200 proves
18
+ // nothing here; case 2 is the one that would have caught the orphan write.
19
+ //
20
+ // Cases 10 and 11 are the regression guard: the resident flow still derives its
21
+ // estate from the people row and still refuses a client-supplied site.
22
+ //
23
+ // Everything it talks to is created and thrown away by the harness: an
24
+ // in-process MongoDB replica set, a loopback Redis, a loopback mail sink. No
25
+ // staging or production database is touched. No real identity is used.
26
+ //
27
+ // Run with: yarn test:e2e
28
+
29
+ import { after, before, describe, it } from "node:test";
30
+ import assert from "node:assert/strict";
31
+ import { ObjectId } from "mongodb";
32
+
33
+ import { startHarness } from "./harness.mjs";
34
+
35
+ const PASSWORD = "Guard-Invite-Passw0rd!";
36
+ const GUARD = "gi-guard@e2e.example.com"; // reaches site A only
37
+ const OUTSIDER = "gi-outsider@e2e.example.com"; // reaches site C only
38
+ const RESIDENT = "gi-resident@e2e.example.com"; // a real people row on site B
39
+
40
+ describe("a guard's invitation is complete, and scoped to their own site", { concurrency: 1 }, () => {
41
+ let h;
42
+ const id = {};
43
+ const result = [];
44
+
45
+ const record = (name, fn) =>
46
+ it(name, async () => {
47
+ try {
48
+ await fn();
49
+ result.push(["PASS", name]);
50
+ } catch (error) {
51
+ result.push(["FAIL", name]);
52
+ throw error;
53
+ }
54
+ });
55
+
56
+ before(async () => {
57
+ h = await startHarness();
58
+ Object.assign(id, await seed(h));
59
+ }, { timeout: 300000 });
60
+
61
+ after(async () => {
62
+ if (h) await h.stop();
63
+ console.log("--- guard invite: per-case result ---");
64
+ for (const [verdict, name] of result) console.log(`${verdict} ${name}`);
65
+ });
66
+
67
+ let guardSid;
68
+ let outsiderSid;
69
+ let residentSid;
70
+
71
+ const guestBody = (site) => ({
72
+ type: "guest",
73
+ expectedCheckIn: "2026-09-10T00:00:00.000Z",
74
+ arrivalTime: "14:30",
75
+ duration: "",
76
+ name: "GI Guest",
77
+ contact: "00000000",
78
+ email: "",
79
+ isOvernightParking: false,
80
+ numberOfPassengers: 0,
81
+ purpose: "Delivery",
82
+ site,
83
+ // Sent by the app, and DELIBERATELY WRONG here: case 3 proves the server
84
+ // ignores it and reads the org off the resolved site instead.
85
+ org: String(id.orgC),
86
+ });
87
+
88
+ record("1 - the guard's own site is accepted", async () => {
89
+ guardSid = await h.login(GUARD, PASSWORD);
90
+ outsiderSid = await h.login(OUTSIDER, PASSWORD);
91
+ residentSid = await h.login(RESIDENT, PASSWORD);
92
+
93
+ const res = await h.api("/visitor-transactions/guard-invite", {
94
+ sid: guardSid,
95
+ method: "POST",
96
+ body: guestBody(String(id.siteA)),
97
+ });
98
+
99
+ assert.equal(res.status, 200, JSON.stringify(res.body));
100
+ assert.ok(res.body.insertedId, "no invitation id came back");
101
+ id.guestInvite = res.body.insertedId;
102
+ });
103
+
104
+ record("2 - the STORED document carries the right site, org and author", async () => {
105
+ const row = await h.db
106
+ .collection("visitor.transactions")
107
+ .findOne({ _id: new ObjectId(String(id.guestInvite)) });
108
+
109
+ assert.ok(row, "the invitation was not written at all");
110
+
111
+ // The three fields the resident path would have left empty.
112
+ assert.equal(String(row.site), String(id.siteA), "wrong or missing site");
113
+ assert.equal(String(row.org), String(id.orgA), "wrong or missing org");
114
+ assert.equal(String(row.inviterId), String(id.guard), "wrong author");
115
+
116
+ // A gate invitation names no home.
117
+ assert.equal(row.block, null);
118
+ assert.equal(row.level, null);
119
+ assert.equal(row.unit, null);
120
+ assert.equal(row.unitName, null);
121
+
122
+ // And the visitor's own details survived the round trip.
123
+ assert.equal(row.name, "GI Guest");
124
+ assert.equal(row.type, "guest");
125
+ assert.equal(row.status, "approved");
126
+ });
127
+
128
+ record("3 - a wrong `org` in the body is ignored, not written", async () => {
129
+ const row = await h.db
130
+ .collection("visitor.transactions")
131
+ .findOne({ _id: new ObjectId(String(id.guestInvite)) });
132
+
133
+ assert.notEqual(
134
+ String(row.org),
135
+ String(id.orgC),
136
+ "the body's org reached the record - a client can misattribute an invitation",
137
+ );
138
+ });
139
+
140
+ record("4 - a contractor invitation stores nric, company and its members", async () => {
141
+ const res = await h.api("/visitor-transactions/guard-invite", {
142
+ sid: guardSid,
143
+ method: "POST",
144
+ body: {
145
+ type: "contractor",
146
+ expectedCheckIn: "2026-09-10T00:00:00.000Z",
147
+ contractorType: "home-contractor",
148
+ name: "GI Contractor",
149
+ nric: "S0000000A",
150
+ email: "",
151
+ contact: "00000000",
152
+ company: "GI Pte Ltd",
153
+ plateNumber: "SGX0000A",
154
+ members: [{ name: "GI Member", nric: "S0000001B", contact: "00000000", visitorPass: "" }],
155
+ purpose: "Servicing",
156
+ site: String(id.siteA),
157
+ org: String(id.orgA),
158
+ },
159
+ });
160
+
161
+ assert.equal(res.status, 200, JSON.stringify(res.body));
162
+
163
+ const row = await h.db
164
+ .collection("visitor.transactions")
165
+ .findOne({ _id: new ObjectId(String(res.body.insertedId)) });
166
+
167
+ assert.equal(String(row.site), String(id.siteA));
168
+ assert.equal(String(row.org), String(id.orgA));
169
+ assert.equal(String(row.inviterId), String(id.guard));
170
+ assert.equal(row.nric, "S0000000A");
171
+ assert.equal(row.company, "GI Pte Ltd");
172
+ assert.equal(row.members[0].name, "GI Member");
173
+ assert.equal(row.members[0].nric, "S0000001B");
174
+ // The guard app seeds `visitorPass: ""` and never fills it; the schema
175
+ // drops it rather than writing an empty string into a field the pass-key
176
+ // screens read as an array of key references.
177
+ assert.ok(
178
+ row.members[0].visitorPass === undefined ||
179
+ Array.isArray(row.members[0].visitorPass),
180
+ "an empty string reached the visitorPass field",
181
+ );
182
+ // The site does not auto-approve, so a contractor group waits.
183
+ assert.equal(row.status, "pending");
184
+ });
185
+
186
+ record("5 - a site the guard cannot reach is REFUSED and writes nothing", async () => {
187
+ const before = await h.db
188
+ .collection("visitor.transactions")
189
+ .countDocuments({ site: String(id.siteC) });
190
+
191
+ const res = await h.api("/visitor-transactions/guard-invite", {
192
+ sid: guardSid,
193
+ method: "POST",
194
+ body: guestBody(String(id.siteC)),
195
+ });
196
+
197
+ // 404, not 403, and deliberately: `entitleSite` answers for a site outside
198
+ // the caller's reach exactly as it answers for a site that does not exist,
199
+ // so site ids cannot be enumerated by watching the difference. Every other
200
+ // module on this platform refuses the same way.
201
+ assert.equal(res.status, 404, JSON.stringify(res.body));
202
+
203
+ const written = await h.db
204
+ .collection("visitor.transactions")
205
+ .countDocuments({ site: String(id.siteC) });
206
+ assert.equal(written, before, "a refused invitation still wrote a row");
207
+ });
208
+
209
+ record("6 - a site that does not exist is refused identically", async () => {
210
+ const res = await h.api("/visitor-transactions/guard-invite", {
211
+ sid: guardSid,
212
+ method: "POST",
213
+ body: guestBody(String(new ObjectId())),
214
+ });
215
+
216
+ assert.equal(res.status, 404, JSON.stringify(res.body));
217
+ });
218
+
219
+ record("7 - an unsigned-in caller cannot invite at all", async () => {
220
+ const res = await h.api("/visitor-transactions/guard-invite", {
221
+ method: "POST",
222
+ body: guestBody(String(id.siteA)),
223
+ });
224
+
225
+ assert.equal(res.status, 401);
226
+ });
227
+
228
+ record("8 - a body with no site is refused before any reach check", async () => {
229
+ const { site, ...noSite } = guestBody(String(id.siteA));
230
+ const res = await h.api("/visitor-transactions/guard-invite", {
231
+ sid: guardSid,
232
+ method: "POST",
233
+ body: noSite,
234
+ });
235
+
236
+ assert.equal(res.status, 400, JSON.stringify(res.body));
237
+ assert.match(String(res.body.message ?? ""), /"site" is required/);
238
+ });
239
+
240
+ record("9 - an outsider cannot invite into the guard's estate", async () => {
241
+ const res = await h.api("/visitor-transactions/guard-invite", {
242
+ sid: outsiderSid,
243
+ method: "POST",
244
+ body: guestBody(String(id.siteA)),
245
+ });
246
+
247
+ assert.equal(res.status, 404, JSON.stringify(res.body));
248
+ });
249
+
250
+ record("10 - REGRESSION: the resident invite still works and still uses the people row", async () => {
251
+ const res = await h.api(`/visitor-transactions/invite/${String(id.resident)}`, {
252
+ sid: residentSid,
253
+ method: "POST",
254
+ body: {
255
+ type: "guest",
256
+ expectedCheckIn: "2026-09-10T00:00:00.000Z",
257
+ arrivalTime: "10:00",
258
+ name: "GI Resident Guest",
259
+ contact: "00000000",
260
+ purpose: "Visit",
261
+ },
262
+ });
263
+
264
+ assert.equal(res.status, 200, JSON.stringify(res.body));
265
+
266
+ const row = await h.db
267
+ .collection("visitor.transactions")
268
+ .findOne({ _id: new ObjectId(String(res.body.insertedId)) });
269
+
270
+ // Everything from the people row, exactly as before this change.
271
+ assert.equal(String(row.site), String(id.siteB));
272
+ assert.equal(String(row.org), String(id.orgB));
273
+ assert.equal(String(row.block), String(id.blockB));
274
+ assert.equal(String(row.level), String(id.levelB));
275
+ assert.equal(String(row.unit), String(id.unitB));
276
+ assert.equal(row.unitName, "01-01");
277
+ assert.equal(String(row.inviterId), String(id.resident));
278
+ });
279
+
280
+ record("11 - REGRESSION: the resident endpoint still refuses a client-supplied site", async () => {
281
+ const res = await h.api(`/visitor-transactions/invite/${String(id.resident)}`, {
282
+ sid: residentSid,
283
+ method: "POST",
284
+ body: {
285
+ type: "guest",
286
+ expectedCheckIn: "2026-09-10T00:00:00.000Z",
287
+ name: "GI Resident Guest 2",
288
+ site: String(id.siteA),
289
+ org: String(id.orgA),
290
+ },
291
+ });
292
+
293
+ assert.equal(res.status, 400, JSON.stringify(res.body));
294
+ assert.match(String(res.body.message ?? ""), /"site" is not allowed/);
295
+ });
296
+ });
297
+
298
+ async function seed(h) {
299
+ const now = new Date().toISOString();
300
+ const orgA = new ObjectId();
301
+ const orgB = new ObjectId();
302
+ const orgC = new ObjectId();
303
+ const siteA = new ObjectId();
304
+ const siteB = new ObjectId();
305
+ const siteC = new ObjectId();
306
+ const roleA = new ObjectId();
307
+ const roleB = new ObjectId();
308
+ const roleC = new ObjectId();
309
+ const blockB = new ObjectId();
310
+ const levelB = new ObjectId();
311
+ const unitB = new ObjectId();
312
+
313
+ await h.db.collection("organizations").insertMany([
314
+ { _id: orgA, name: "GI Org A", email: "gia@e2e.example.com", type: "org", nature: "property_management_agency", status: "active", createdAt: now },
315
+ { _id: orgB, name: "GI Org B", email: "gib@e2e.example.com", type: "org", nature: "property_management_agency", status: "active", createdAt: now },
316
+ { _id: orgC, name: "GI Org C", email: "gic@e2e.example.com", type: "org", nature: "property_management_agency", status: "active", createdAt: now },
317
+ ]);
318
+
319
+ await h.db.collection("sites").insertMany([
320
+ { _id: siteA, name: "GI Site A", org: orgA, orgId: orgA, status: "active" },
321
+ { _id: siteB, name: "GI Site B", org: orgB, orgId: orgB, status: "active" },
322
+ { _id: siteC, name: "GI Site C", org: orgC, orgId: orgC, status: "active" },
323
+ ]);
324
+
325
+ await h.db.collection("buildings").insertOne({ _id: blockB, name: "Block 1", site: siteB, org: orgB, status: "active" });
326
+ await h.db.collection("building-levels").insertOne({ _id: levelB, name: "01", block: blockB, site: siteB, org: orgB, status: "active" });
327
+ await h.db.collection("building-units").insertOne({ _id: unitB, name: "01-01", level: levelB, block: blockB, site: siteB, org: orgB, status: "active" });
328
+
329
+ await h.db.collection("roles").insertMany([
330
+ { _id: roleA, name: "GI Guard A", org: orgA, permissions: ["*"], status: "active" },
331
+ { _id: roleB, name: "GI Resident B", org: orgB, permissions: ["*"], status: "active" },
332
+ { _id: roleC, name: "GI Manager C", org: orgC, permissions: ["*"], status: "active" },
333
+ ]);
334
+
335
+ const emails = [GUARD, OUTSIDER, RESIDENT];
336
+ const users = await h.db.collection("users").insertMany(
337
+ emails.map((email) => ({ email, name: email.split("@")[0], status: "active", createdAt: now })),
338
+ );
339
+
340
+ const hashed = await h.hashPassword(PASSWORD);
341
+ await h.db.collection("users").updateMany({ email: { $in: emails } }, { $set: { password: hashed } });
342
+
343
+ const [guard, outsider, resident] = [0, 1, 2].map((i) => users.insertedIds[i]);
344
+
345
+ await h.db.collection("members").insertMany([
346
+ // the guard: pinned to site A, and holding NO site.people row - which is
347
+ // exactly why the resident invite path cannot serve them
348
+ { user: guard, org: orgA, siteId: siteA, type: "organization", role: roleA, status: "active" },
349
+ { user: outsider, org: orgC, siteId: siteC, type: "organization", role: roleC, status: "active" },
350
+ { user: resident, org: orgB, siteId: siteB, type: "organization", role: roleB, status: "active" },
351
+ ]);
352
+
353
+ // the resident's people row - the estate the resident path copies from
354
+ await h.db.collection("site.people").insertOne({
355
+ user: resident,
356
+ name: "GI Resident",
357
+ org: orgB,
358
+ site: siteB,
359
+ block: blockB,
360
+ level: levelB,
361
+ unit: unitB,
362
+ type: "resident",
363
+ status: "active",
364
+ createdAt: now,
365
+ });
366
+
367
+ return { orgA, orgB, orgC, siteA, siteB, siteC, guard, outsider, resident, blockB, levelB, unitB };
368
+ }
@@ -1212,6 +1212,23 @@ function buildApp(nsu, core) {
1212
1212
  siteBillingItems.delete("/id/:id", requireAuth, deleteById);
1213
1213
  }
1214
1214
 
1215
+ // mirrors iservice365-API-core/src/routes/visitor-transaction.route.ts
1216
+ //
1217
+ // `requireInviterScope` lives in API-core and cannot be imported, so the
1218
+ // resident route is mounted with `requireAuth` alone here - the tests on it
1219
+ // are regression tests about what the SERVICE derives, not about that
1220
+ // middleware. The guard route carries no such middleware in API-core either:
1221
+ // its scope check is `requireSiteReach`, inside the controller, which is the
1222
+ // real thing in this process.
1223
+ const visitorTransactions = express.Router();
1224
+ {
1225
+ const { inviteVisitor, inviteVisitorAsGuard } =
1226
+ core.useVisitorTransactionController();
1227
+
1228
+ visitorTransactions.post("/guard-invite", requireAuth, inviteVisitorAsGuard);
1229
+ visitorTransactions.post("/invite/:inviterId", requireAuth, inviteVisitor);
1230
+ }
1231
+
1215
1232
  // mirrors iservice365-API-core/src/routes/guest-management.route.ts
1216
1233
  const visitorGuests = express.Router();
1217
1234
  {
@@ -1499,6 +1516,7 @@ function buildApp(nsu, core) {
1499
1516
  app.use("/api/site-billing-configuration", siteBillingConfiguration);
1500
1517
  app.use("/api/site-billing-items", siteBillingItems);
1501
1518
  app.use("/api/visitor-guests", visitorGuests);
1519
+ app.use("/api/visitor-transactions", visitorTransactions);
1502
1520
  app.use("/api/facility-bookings", facilityBookings);
1503
1521
  app.use("/api/manpower-designations", manpowerDesignations);
1504
1522
  app.use("/api/site-facilities", siteFacilities);