@7365admin1/core 3.135.0 → 3.136.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@7365admin1/core",
3
3
  "license": "MIT",
4
- "version": "3.135.0",
4
+ "version": "3.136.0",
5
5
  "author": "7365admin1",
6
6
  "main": "dist/index.js",
7
7
  "module": "dist/index.mjs",
@@ -205,6 +205,9 @@ function startMailSink() {
205
205
  sock.write("221 Bye\r\n");
206
206
  sock.end();
207
207
  return;
208
+ } else if (cmd === "RCPT" && /bounce/i.test(line)) {
209
+ // a mailbox the server refuses - proves a failed send is recorded
210
+ sock.write("550 5.1.1 Mailbox unavailable\r\n");
208
211
  } else {
209
212
  sock.write("250 2.0.0 Ok\r\n"); // AUTH, MAIL FROM, RCPT TO, RSET, NOOP
210
213
  }
@@ -566,6 +569,10 @@ function buildApp(nsu, core) {
566
569
  remove,
567
570
  decline,
568
571
  accept,
572
+ acceptNewCompany,
573
+ wrongJoinPreview,
574
+ wrongJoinRepair,
575
+ wrongJoinUndo,
569
576
  linkSite,
570
577
  received,
571
578
  getOne,
@@ -583,6 +590,10 @@ function buildApp(nsu, core) {
583
590
  invitations.get("/:id", requireAuth, getOne);
584
591
  invitations.put("/:id/decline", requireAuth, decline);
585
592
  invitations.put("/:id/accept", requireAuth, accept);
593
+ invitations.put("/:id/accept-new-company", requireAuth, acceptNewCompany);
594
+ invitations.get("/:id/wrong-join", requireAuth, wrongJoinPreview);
595
+ invitations.put("/:id/wrong-join/repair", requireAuth, wrongJoinRepair);
596
+ invitations.put("/:id/wrong-join/undo", requireAuth, wrongJoinUndo);
586
597
  invitations.put("/:id/link-site", requireAuth, linkSite);
587
598
  }
588
599
 
@@ -116,10 +116,12 @@ describe("self-enrolment only with a role the caller may give themselves", { con
116
116
  assert.equal((await enrol("NEWCOMER", "NEWCOMER", "narrowRole", "property_management_agency")).status, 201);
117
117
  });
118
118
 
119
- record("6. SP sign-in: the org's Admin Service Provider role of the invite's app, both rows", async () => {
119
+ record("6. CLOSED 2026-09-30: an SP invitation never enrols the provider into the org that SENT it", async () => {
120
+ // the provider joins as its own company (accept / accept-new-company); the
121
+ // old sign-in wrote it into the client's organisation with a "*" role
120
122
  const extra = { siteId: id.siteB.toString(), siteName: "SE Site B" };
121
- assert.equal((await enrol("SP", "SP", "spRole", "organization", extra)).status, 201);
122
- assert.equal((await enrol("SP", "SP", "spRole", "security_agency", extra)).status, 201);
123
+ assert.equal((await enrol("SP", "SP", "spRole", "organization", extra)).status, 401);
124
+ assert.equal((await enrol("SP", "SP", "spRole", "security_agency", extra)).status, 401);
123
125
  });
124
126
 
125
127
  record("7. onboarding owner: organisation row and app row with the owner role", async () => {
@@ -441,10 +441,12 @@ async function seed(h) {
441
441
  ]);
442
442
 
443
443
  await h.db.collection("verifications").insertMany([
444
- // the service-provider invitation the sign-in flow reads: addressed to the
445
- // newcomer, naming org B, stored as an ObjectId — the shape MVerification writes
444
+ // a member invitation into org B (the admin-member sign-up), addressed to
445
+ // the newcomer, stored as an ObjectId — the shape MVerification writes. A
446
+ // service-provider invitation no longer reaches the org that sent it
447
+ // (2026-09-30), see member-direct-self-enrol-role test 6.
446
448
  {
447
- type: "service-provider-invite",
449
+ type: "member-invite",
448
450
  email: NEWCOMER,
449
451
  status: "pending",
450
452
  metadata: { org: orgB, siteId: siteB, app: "security_agency", role: roleB },
@@ -257,7 +257,8 @@ describe("service-provider invitation approval, end to end", { concurrency: 1 },
257
257
 
258
258
  // the audit trail
259
259
  const actions = after.history.map((e) => e.action);
260
- assert.deepEqual(actions.slice(0, 3), ["created", "approved", "provider-notified"]);
260
+ // the send's own outcome is recorded before "provider-notified"
261
+ assert.deepEqual(actions.slice(0, 4), ["created", "approved", "email-sent", "provider-notified"]);
261
262
  assert.equal(after.history[1].byName, "Sasha Seven");
262
263
  });
263
264
 
@@ -431,6 +432,20 @@ describe("service-provider invitation approval, end to end", { concurrency: 1 },
431
432
  const rowsBefore = await h.db
432
433
  .collection("verifications")
433
434
  .countDocuments({ email: PROVIDER_EMAIL, "metadata.siteId": id.siteD });
435
+ // right after the approval email, Resend is held back (rate limit)...
436
+ const tooSoon = await h.api(`/service-provider-invitations/${inviteD}/resend`, {
437
+ method: "PUT",
438
+ sid: pmSid,
439
+ });
440
+ assert.equal(tooSoon.status, 400);
441
+ assert.match(tooSoon.body.message, /resend it in/);
442
+ // ...until the last attempt is old enough
443
+ await h.db.collection("verifications").updateOne(
444
+ { _id: new ObjectId(inviteD) },
445
+ { $set: { "history.$[e].at": new Date(Date.now() - 10 * 60_000).toISOString() } },
446
+ { arrayFilters: [{ "e.action": "email-sent" }] },
447
+ );
448
+
434
449
  const mailsBefore = h.mails.length;
435
450
 
436
451
  const res = await h.api(`/service-provider-invitations/${inviteD}/resend`, {
@@ -0,0 +1,425 @@
1
+ // End-to-end proof for the owner's report of 2026-09-30 (Latitude / JLL
2
+ // Singapore invited latitude.sec@ as Security):
3
+ //
4
+ // A. "No email was received" - the invitation was still waiting for Seven365.
5
+ // Now: every Seven365 administrator is emailed (not only those who opted
6
+ // in) when an invitation waits; approval sends the provider's email and
7
+ // RECORDS the outcome on the invitation ("email-sent" / "email-failed" +
8
+ // reason); a failed send is shown, not thrown, and Resend (rate-limited)
9
+ // tries again.
10
+ // B. "Signed up as a new company but it shows as Property Management" - the
11
+ // browser enrolled the invitee into the SENDER's organisation. Now: an SP
12
+ // invitation never reaches the sender's organisation, and a new company
13
+ // is created as its OWN organisation typed by the invited service, owner
14
+ // in that service's app, with the site on its list.
15
+ //
16
+ // Real Express stack, in-process MongoDB, loopback mail sink (a "bounce"
17
+ // address is refused by the sink with 550). Nothing leaves the machine.
18
+ //
19
+ // Run with: yarn test:e2e
20
+
21
+ import { after, before, describe, it } from "node:test";
22
+ import assert from "node:assert/strict";
23
+ import { ObjectId } from "mongodb";
24
+
25
+ import { startHarness } from "./harness.mjs";
26
+
27
+ const PASSWORD = "Inv-Passw0rd!";
28
+ const PM_EMAIL = "pm-inv@e2e.example.com";
29
+ // staff address on the non-production allow-list (*@seven365.com.sg); the
30
+ // mailer points at the loopback sink, so nothing is really sent
31
+ const ADMIN_EMAIL = "sp-approvals-e2e@seven365.com.sg";
32
+ const EXISTING_EMAIL = "existing-guard@e2e.example.com";
33
+
34
+ const SERVICES = [
35
+ ["security_agency", "security_agency"],
36
+ ["cleaning_services", "cleaning_services"],
37
+ ["mechanical_electrical_services", "mechanical_electrical_services"],
38
+ ["pest_control_services", "pest_control_services"],
39
+ ["landscaping_services", "landscaping_services"],
40
+ ["pool_maintenance_services", "pool_maintenance_services"],
41
+ // an older row carrying the short app name
42
+ ["security", "security_agency"],
43
+ ];
44
+
45
+ describe("SP invitation: email status, Super Admin alert, new company typed by service", { concurrency: 1 }, () => {
46
+ let h;
47
+ let id;
48
+ let pmSid;
49
+ let adminSid;
50
+
51
+ before(async () => {
52
+ h = await startHarness();
53
+ id = await seed(h);
54
+ pmSid = await h.login(PM_EMAIL, PASSWORD);
55
+ adminSid = await h.login(ADMIN_EMAIL, PASSWORD);
56
+ }, { timeout: 300000 });
57
+
58
+ after(async () => {
59
+ if (h) await h.stop();
60
+ });
61
+
62
+ async function invite(email, app, site = id.site) {
63
+ const res = await h.api("/service-providers/invite", {
64
+ method: "POST",
65
+ sid: pmSid,
66
+ body: {
67
+ email,
68
+ orgId: id.pmOrg.toString(),
69
+ siteId: site.toString(),
70
+ siteName: "Latitude",
71
+ inviteType: "organization-invite",
72
+ app,
73
+ },
74
+ });
75
+ assert.equal(res.status, 200, JSON.stringify(res.body));
76
+ const row = await h.db.collection("verifications").findOne({ email, "metadata.siteId": site });
77
+ assert.equal(row.status, "awaiting-approval");
78
+ return row._id.toString();
79
+ }
80
+
81
+ const approve = (inviteId) =>
82
+ h.api(`/service-provider-invitations/${inviteId}/approve`, { method: "PUT", sid: adminSid });
83
+
84
+ async function newAccount(email) {
85
+ await h.db.collection("users").insertOne({
86
+ email,
87
+ password: await h.hashPassword(PASSWORD),
88
+ name: "New Co Owner",
89
+ status: "active",
90
+ createdAt: new Date().toISOString(),
91
+ });
92
+ return h.login(email, PASSWORD);
93
+ }
94
+
95
+ // ---------------------------------------------------------------- A. email
96
+
97
+ it("A1. pending: every Seven365 administrator is emailed, not only those who opted in", async () => {
98
+ const before = h.mails.length;
99
+ await invite("waiting-co@e2e.example.com", "security_agency", id.site2);
100
+
101
+ const mail = await h.mailAt(before);
102
+ // the sink keeps the multipart body quoted-printable: undo soft breaks and =3D
103
+ const body = mail.body.replace(/=\r?\n/g, "").replace(/=3D/g, "=");
104
+ assert.match(mail.to, /sp-approvals-e2e@seven365\.com\.sg/);
105
+ assert.match(mail.subject, /Service provider invitation to approve/);
106
+ assert.match(body, /only after a Seven365 administrator approves/);
107
+ assert.match(body, /notifications\/open\?kind=spApproval/);
108
+ // and the bell
109
+ const bell = await h.db.collection("notifications").findOne({ userId: id.adminUser, title: /to approve/ });
110
+ assert.ok(bell, "the in-app bell row is written too");
111
+ // nothing reached the provider
112
+ assert.ok(!h.mails.some((m) => /waiting-co@/.test(m.to)));
113
+ });
114
+
115
+ it("A2. the inviter sees who must act while it waits", async () => {
116
+ const res = await h.api(`/service-provider-invitations?org=${id.pmOrg}&siteId=${id.site2}`, { sid: pmSid });
117
+ assert.equal(res.status, 200, JSON.stringify(res.body));
118
+ const row = res.body.items.find((r) => r.email === "waiting-co@e2e.example.com");
119
+ assert.equal(row.statusLabel, "Waiting for Seven365");
120
+ assert.equal(row.emailStatus, null, "no email has been attempted yet");
121
+ });
122
+
123
+ it("A3. approve -> the provider's email is sent and the invitation says so", async () => {
124
+ const inviteId = await invite("sent-co@e2e.example.com", "security_agency", id.site3);
125
+ await new Promise((r) => setTimeout(r, 200)); // let the staff email drain
126
+ const before = h.mails.length;
127
+
128
+ const res = await approve(inviteId);
129
+ assert.equal(res.status, 200, JSON.stringify(res.body));
130
+ assert.equal(res.body.emailSent, true);
131
+ assert.match(res.body.message, /email has been sent/);
132
+
133
+ const mail = await h.mailAt(before);
134
+ assert.match(mail.to, /sent-co@/);
135
+ assert.match(mail.body, new RegExp(`/verify/service-provider-invite/${inviteId}`));
136
+
137
+ const list = await h.api(`/service-provider-invitations?org=${id.pmOrg}&siteId=${id.site3}`, { sid: pmSid });
138
+ const row = list.body.items.find((r) => r._id === inviteId);
139
+ assert.equal(row.emailStatus.state, "sent");
140
+ assert.ok(row.emailStatus.at);
141
+ });
142
+
143
+ it("A4. a refused send: approved anyway, status 'failed' + reason, no 500, Resend offered", async () => {
144
+ const inviteId = await invite("bounce-co@e2e.example.com", "cleaning_services", id.site4);
145
+ const res = await approve(inviteId);
146
+ assert.equal(res.status, 200, JSON.stringify(res.body));
147
+ assert.equal(res.body.emailSent, false);
148
+ assert.match(res.body.message, /could not be sent: the email address was refused/);
149
+ assert.match(res.body.message, /Resend/);
150
+
151
+ const row = await h.db.collection("verifications").findOne({ _id: new ObjectId(inviteId) });
152
+ assert.equal(row.status, "pending", "approval stands");
153
+ const last = row.history.at(-1);
154
+ assert.equal(last.action, "email-failed");
155
+ assert.match(last.reason, /refused/);
156
+ assert.ok(!row.history.some((e) => e.action === "provider-notified"), "not claimed as notified");
157
+ // no secret anywhere in what was stored
158
+ assert.ok(!JSON.stringify(row.history).includes("e2e-mailer-secret"));
159
+
160
+ const list = await h.api(`/service-provider-invitations?org=${id.pmOrg}&siteId=${id.site4}`, { sid: pmSid });
161
+ const view = list.body.items.find((r) => r._id === inviteId);
162
+ assert.equal(view.emailStatus.state, "failed");
163
+ assert.match(view.emailStatus.reason, /refused/);
164
+ });
165
+
166
+ it("A5. Resend is rate-limited, then records its own outcome", async () => {
167
+ const row = await h.db.collection("verifications").findOne({ email: "bounce-co@e2e.example.com" });
168
+ const inviteId = row._id.toString();
169
+
170
+ const early = await h.api(`/service-provider-invitations/${inviteId}/resend`, { method: "PUT", sid: pmSid });
171
+ assert.equal(early.status, 400);
172
+ assert.match(early.body.message, /resend it in \d+ minute/);
173
+
174
+ // the clock moves on: age the last attempt past the limit
175
+ await h.db.collection("verifications").updateOne(
176
+ { _id: row._id },
177
+ { $set: { "history.$[e].at": new Date(Date.now() - 10 * 60_000).toISOString() } },
178
+ { arrayFilters: [{ "e.action": "email-failed" }] },
179
+ );
180
+ // fix the address, as the sender would (a new send to a good mailbox)
181
+ await h.db.collection("verifications").updateOne({ _id: row._id }, { $set: { email: "fixed-co@e2e.example.com" } });
182
+
183
+ const before = h.mails.length;
184
+ const again = await h.api(`/service-provider-invitations/${inviteId}/resend`, { method: "PUT", sid: pmSid });
185
+ assert.equal(again.status, 200, JSON.stringify(again.body));
186
+ assert.match((await h.mailAt(before)).to, /fixed-co@/);
187
+ const after = await h.db.collection("verifications").findOne({ _id: row._id });
188
+ assert.equal(after.history.at(-1).action, "email-sent");
189
+ });
190
+
191
+ it("A6. the Super Admin may resend too; an outsider may not", async () => {
192
+ const row = await h.db.collection("verifications").findOne({ email: "sent-co@e2e.example.com" });
193
+ await h.db.collection("verifications").updateOne(
194
+ { _id: row._id },
195
+ { $set: { "history.$[e].at": new Date(Date.now() - 10 * 60_000).toISOString() } },
196
+ { arrayFilters: [{ "e.action": "email-sent" }] },
197
+ );
198
+ const res = await h.api(`/service-provider-invitations/${row._id}/resend`, { method: "PUT", sid: adminSid });
199
+ assert.equal(res.status, 200, JSON.stringify(res.body));
200
+ });
201
+
202
+ // ---------------------------------------------------- B. new company typed
203
+
204
+ for (const [app, nature] of SERVICES) {
205
+ it(`B. new company invited for ${app} -> its own ${nature} organisation, owner in that app, site linked`, async () => {
206
+ const email = `newco-${app.replace(/_/g, "-")}@e2e.example.com`;
207
+ const site = new ObjectId();
208
+ await h.db.collection("sites").insertOne({ _id: site, name: "Latitude", orgId: id.pmOrg, status: "active" });
209
+ const inviteId = await invite(email, app, site);
210
+ await approve(inviteId);
211
+
212
+ const sid = await newAccount(email);
213
+ const res = await h.api(`/service-provider-invitations/${inviteId}/accept-new-company`, {
214
+ method: "PUT",
215
+ sid,
216
+ body: { companyName: `New ${app} Pte Ltd`, country: "Singapore" },
217
+ });
218
+ assert.equal(res.status, 200, JSON.stringify(res.body));
219
+ assert.equal(res.body.nature, nature);
220
+
221
+ const org = await h.db.collection("organizations").findOne({ _id: new ObjectId(res.body.orgId) });
222
+ assert.equal(org.nature, nature, "typed by the invited service");
223
+ assert.equal(org.type, nature);
224
+ assert.equal(org.name, `New ${app} Pte Ltd`);
225
+ assert.notEqual(org._id.toString(), id.pmOrg.toString());
226
+
227
+ const user = await h.db.collection("users").findOne({ email });
228
+ assert.equal(user.defaultOrg?.toString(), org._id.toString());
229
+
230
+ const rows = await h.db.collection("members").find({ user: user._id, status: "active" }).toArray();
231
+ assert.deepEqual(rows.map((m) => m.type).sort(), ["organization", nature].sort());
232
+ assert.ok(rows.every((m) => m.org.toString() === org._id.toString()), "memberships only in its own company");
233
+ assert.equal(
234
+ await h.db.collection("members").countDocuments({ user: user._id, org: { $in: [id.pmOrg, id.pmOrg.toString()] } }),
235
+ 0,
236
+ "never a member of the property manager",
237
+ );
238
+
239
+ const link = await h.db.collection("customer.sites").findOne({ org: org._id, site });
240
+ assert.ok(link, "the site is on the new company's list as a service provider");
241
+ const after = await h.db.collection("verifications").findOne({ _id: new ObjectId(inviteId) });
242
+ assert.equal(after.status, "complete");
243
+ });
244
+ }
245
+
246
+ it("B2. an EXISTING company is not re-created: refused here, accepts as itself, unchanged", async () => {
247
+ const site = new ObjectId();
248
+ await h.db.collection("sites").insertOne({ _id: site, name: "Latitude", orgId: id.pmOrg, status: "active" });
249
+ const inviteId = await invite(EXISTING_EMAIL, "security_agency", site);
250
+ await approve(inviteId);
251
+ const sid = await h.login(EXISTING_EMAIL, PASSWORD);
252
+ const orgsBefore = await h.db.collection("organizations").countDocuments();
253
+
254
+ const refused = await h.api(`/service-provider-invitations/${inviteId}/accept-new-company`, {
255
+ method: "PUT",
256
+ sid,
257
+ body: { companyName: "Dup" },
258
+ });
259
+ assert.equal(refused.status, 400);
260
+ assert.match(refused.body.message, /already have a Security company/);
261
+
262
+ const ok = await h.api(`/service-provider-invitations/${inviteId}/accept`, {
263
+ method: "PUT",
264
+ sid,
265
+ body: { org: id.existingOrg.toString() },
266
+ });
267
+ assert.equal(ok.status, 200, JSON.stringify(ok.body));
268
+ assert.equal(await h.db.collection("organizations").countDocuments(), orgsBefore);
269
+ const org = await h.db.collection("organizations").findOne({ _id: id.existingOrg });
270
+ assert.equal(org.nature, "security_agency");
271
+ assert.ok(await h.db.collection("customer.sites").findOne({ org: id.existingOrg, site }));
272
+ });
273
+
274
+ it("B3. only the invited account; not while held; not twice", async () => {
275
+ const site = new ObjectId();
276
+ await h.db.collection("sites").insertOne({ _id: site, name: "Latitude", orgId: id.pmOrg, status: "active" });
277
+ const inviteId = await invite("held-co@e2e.example.com", "security_agency", site);
278
+ const sid = await newAccount("held-co@e2e.example.com");
279
+
280
+ const held = await h.api(`/service-provider-invitations/${inviteId}/accept-new-company`, { method: "PUT", sid, body: {} });
281
+ assert.equal(held.status, 404, "not before Seven365 approves");
282
+
283
+ await approve(inviteId);
284
+ const other = await h.api(`/service-provider-invitations/${inviteId}/accept-new-company`, { method: "PUT", sid: pmSid, body: {} });
285
+ assert.equal(other.status, 401);
286
+
287
+ const first = await h.api(`/service-provider-invitations/${inviteId}/accept-new-company`, { method: "PUT", sid, body: { companyName: "Held Co" } });
288
+ assert.equal(first.status, 200, JSON.stringify(first.body));
289
+ const second = await h.api(`/service-provider-invitations/${inviteId}/accept-new-company`, { method: "PUT", sid, body: { companyName: "Held Co 2" } });
290
+ assert.equal(second.status, 400);
291
+ });
292
+
293
+ it("B4. the old browser path is closed: an SP invitee cannot enrol into the SENDER's organisation", async () => {
294
+ const site = new ObjectId();
295
+ await h.db.collection("sites").insertOne({ _id: site, name: "Latitude", orgId: id.pmOrg, status: "active" });
296
+ const email = "legacy-co@e2e.example.com";
297
+ const inviteId = await invite(email, "security_agency", site);
298
+ await approve(inviteId);
299
+ const sid = await newAccount(email);
300
+ const user = await h.db.collection("users").findOne({ email });
301
+
302
+ const role = await h.api("/roles", {
303
+ method: "POST",
304
+ sid,
305
+ body: { name: "Admin Service Provider", permissions: ["*"], type: "security_agency", org: id.pmOrg.toString(), site: site.toString(), platform: "website" },
306
+ });
307
+ assert.equal(role.status, 401, JSON.stringify(role.body));
308
+
309
+ const enrol = await h.api("/members/direct", {
310
+ method: "POST",
311
+ sid,
312
+ body: { userId: user._id.toString(), orgId: id.pmOrg.toString(), roleId: id.pmRole.toString(), app: "organization", siteId: site.toString(), siteName: "Latitude" },
313
+ });
314
+ assert.equal(enrol.status, 401, JSON.stringify(enrol.body));
315
+ assert.equal(await h.db.collection("members").countDocuments({ user: user._id }), 0);
316
+ });
317
+
318
+ // ------------------------------------------- C. repair the live wrong join
319
+
320
+ it("C1. Super Admin repair: preview (read only) -> repair -> undo -> repair; then the right path works", async () => {
321
+ // the production state of latitude.sec@: approved invitation, an account,
322
+ // and two memberships the OLD web flow wrote into the SENDER's organisation
323
+ const site = new ObjectId();
324
+ await h.db.collection("sites").insertOne({ _id: site, name: "Latitude", orgId: id.pmOrg, status: "active" });
325
+ const email = "latitude-sec@e2e.example.com";
326
+ const inviteId = await invite(email, "security_agency", site);
327
+ await approve(inviteId);
328
+ const sid = await newAccount(email);
329
+ const user = await h.db.collection("users").findOne({ email });
330
+ const spRole = new ObjectId();
331
+ await h.db.collection("roles").insertOne({ _id: spRole, name: "Admin Service Provider", type: "security_agency", org: id.pmOrg.toString(), site: site.toString(), permissions: ["*"], status: "active" });
332
+ const later = new Date(Date.now() + 1000);
333
+ await h.db.collection("members").insertMany([
334
+ { user: user._id, org: id.pmOrg, role: spRole, type: "organization", siteId: site, siteName: "Latitude", status: "active", createdAt: later },
335
+ { user: user._id, org: id.pmOrg, role: spRole, type: "security_agency", siteId: site, siteName: "Latitude", status: "active", createdAt: later },
336
+ ]);
337
+ await h.db.collection("users").updateOne({ _id: user._id }, { $set: { defaultOrg: id.pmOrg.toString() } });
338
+
339
+ // not for the property manager, not for the invitee
340
+ for (const who of [pmSid, sid]) {
341
+ const refused = await h.api(`/service-provider-invitations/${inviteId}/wrong-join`, { sid: who });
342
+ assert.ok([401, 403].includes(refused.status), JSON.stringify(refused.body));
343
+ }
344
+
345
+ const before = JSON.stringify(await h.db.collection("members").find({ user: user._id }).toArray());
346
+ const preview = await h.api(`/service-provider-invitations/${inviteId}/wrong-join`, { sid: adminSid });
347
+ assert.equal(preview.status, 200, JSON.stringify(preview.body));
348
+ assert.equal(preview.body.senderOrgName, "JLL Singapore");
349
+ assert.equal(preview.body.memberships.length, 2);
350
+ assert.ok(preview.body.memberships.every((m) => m.fullAccess && m.roleName === "Admin Service Provider"));
351
+ assert.equal(preview.body.defaultOrgIsSender, true);
352
+ assert.equal(preview.body.repaired, null);
353
+ assert.equal(JSON.stringify(await h.db.collection("members").find({ user: user._id }).toArray()), before, "preview writes nothing");
354
+
355
+ const repaired = await h.api(`/service-provider-invitations/${inviteId}/wrong-join/repair`, { method: "PUT", sid: adminSid });
356
+ assert.equal(repaired.status, 200, JSON.stringify(repaired.body));
357
+ assert.equal(repaired.body.changed, 2);
358
+ assert.equal(await h.db.collection("members").countDocuments({ user: user._id, status: "active" }), 0);
359
+
360
+ const undone = await h.api(`/service-provider-invitations/${inviteId}/wrong-join/undo`, { method: "PUT", sid: adminSid });
361
+ assert.equal(undone.status, 200, JSON.stringify(undone.body));
362
+ assert.equal(await h.db.collection("members").countDocuments({ user: user._id, status: "active" }), 2);
363
+
364
+ await h.api(`/service-provider-invitations/${inviteId}/wrong-join/repair`, { method: "PUT", sid: adminSid });
365
+ const again = await h.api(`/service-provider-invitations/${inviteId}/wrong-join`, { sid: adminSid });
366
+ assert.equal(again.body.memberships.length, 0);
367
+ assert.ok(again.body.repaired);
368
+ // the pending invitation is untouched by all of this
369
+ assert.equal((await h.db.collection("verifications").findOne({ _id: new ObjectId(inviteId) })).status, "pending");
370
+
371
+ // and now the invitee takes the right path: its own Security company
372
+ const res = await h.api(`/service-provider-invitations/${inviteId}/accept-new-company`, {
373
+ method: "PUT",
374
+ sid,
375
+ body: { companyName: "Latitude Security" },
376
+ });
377
+ assert.equal(res.status, 200, JSON.stringify(res.body));
378
+ const org = await h.db.collection("organizations").findOne({ _id: new ObjectId(res.body.orgId) });
379
+ assert.equal(org.nature, "security_agency");
380
+ assert.ok(await h.db.collection("customer.sites").findOne({ org: org._id, site }));
381
+ assert.equal(
382
+ (await h.db.collection("users").findOne({ _id: user._id })).defaultOrg?.toString(),
383
+ org._id.toString(),
384
+ "the account now opens its own company",
385
+ );
386
+ });
387
+ });
388
+
389
+ async function seed(h) {
390
+ const now = new Date().toISOString();
391
+ const pmOrg = new ObjectId();
392
+ const existingOrg = new ObjectId();
393
+ const pmUser = new ObjectId();
394
+ const adminUser = new ObjectId();
395
+ const existingUser = new ObjectId();
396
+ const adminRole = new ObjectId();
397
+ const pmRole = new ObjectId();
398
+ const existingRole = new ObjectId();
399
+ const [site, site2, site3, site4] = [0, 1, 2, 3].map(() => new ObjectId());
400
+
401
+ await h.db.collection("organizations").insertMany([
402
+ { _id: pmOrg, name: "JLL Singapore", email: "pm-org-inv@e2e.example.com", type: "property_management_agency", nature: "property_management_agency", status: "active", createdAt: now },
403
+ { _id: existingOrg, name: "Existing Guards", email: EXISTING_EMAIL, type: "security_agency", nature: "security_agency", createdBy: existingUser, status: "active", createdAt: now },
404
+ ]);
405
+ await h.db.collection("sites").insertMany(
406
+ [site, site2, site3, site4].map((_id) => ({ _id, name: "Latitude", orgId: pmOrg, status: "active", createdAt: now })),
407
+ );
408
+ const pw = await h.hashPassword(PASSWORD);
409
+ await h.db.collection("users").insertMany([
410
+ { _id: pmUser, email: PM_EMAIL, password: pw, name: "Latitude", status: "active", defaultOrg: pmOrg.toString(), createdAt: now },
411
+ { _id: adminUser, email: ADMIN_EMAIL, password: pw, name: "Master7365", status: "active", createdAt: now },
412
+ { _id: existingUser, email: EXISTING_EMAIL, password: pw, name: "Existing Owner", status: "active", defaultOrg: existingOrg.toString(), createdAt: now },
413
+ ]);
414
+ await h.db.collection("roles").insertMany([
415
+ { _id: adminRole, name: "Super Admin", type: "admin", default: true, permissions: ["*"], status: "active" },
416
+ { _id: pmRole, name: "owner", type: "property_management_agency", org: pmOrg.toString(), permissions: ["*"], status: "active" },
417
+ { _id: existingRole, name: "owner", type: "organization", org: existingOrg.toString(), permissions: ["*"], status: "active" },
418
+ ]);
419
+ await h.db.collection("members").insertMany([
420
+ { user: adminUser, name: "Master7365", role: adminRole, type: "admin", org: "", status: "active", createdAt: now },
421
+ { user: pmUser, name: "Latitude", role: pmRole, type: "property_management_agency", org: pmOrg, status: "active", createdAt: now },
422
+ { user: existingUser, name: "Existing Owner", role: existingRole, type: "organization", org: existingOrg, status: "active", createdAt: now },
423
+ ]);
424
+ return { pmOrg, existingOrg, pmUser, adminUser, pmRole, site, site2, site3, site4 };
425
+ }
@@ -143,8 +143,10 @@ test("only invitation types count, not any verification", () => {
143
143
  const body = fn(inviteActor, "hasOrgInvitation");
144
144
  assert.match(
145
145
  body,
146
- /\$in: \["user-invite", "member-invite", "service-provider-invite"\]/,
146
+ /\$in: \["user-invite", "member-invite"\]/,
147
147
  );
148
+ // a service-provider invitation names the SENDER: never a key to it (2026-09-30)
149
+ assert.doesNotMatch(body, /"service-provider-invite"/);
148
150
  // a forgotten-password or sign-up code must not be a key to an organisation
149
151
  assert.doesNotMatch(body, /forget-password|user-sign-up/);
150
152
  });
@@ -0,0 +1,74 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+
4
+ import {
5
+ inviteEmailStatus,
6
+ inviteEmailFailureReason,
7
+ inviteResendWaitMinutes,
8
+ serviceProviderNatureFor,
9
+ } from "./.build/utils/service-provider-invite.util.mjs";
10
+ import { notificationEmail } from "./.build/utils/notification-email.util.mjs";
11
+
12
+ // Owner report 2026-09-30: did the invitation email go out, and why not.
13
+
14
+ test("email status is the LATEST send attempt", () => {
15
+ assert.equal(inviteEmailStatus([{ action: "created" }]), null);
16
+ assert.deepEqual(
17
+ inviteEmailStatus([
18
+ { action: "email-failed", at: "t1", reason: "could not reach the mail server" },
19
+ { action: "resent", at: "t2" },
20
+ { action: "email-sent", at: "t3" },
21
+ ]),
22
+ { state: "sent", at: "t3" },
23
+ );
24
+ assert.equal(inviteEmailStatus([{ action: "email-failed", at: "t" }]).reason, "unknown error");
25
+ });
26
+
27
+ test("failure reasons come from the error CODE only, never its message", () => {
28
+ const leaky = { code: "EAUTH", message: "Invalid login: user@x pass=hunter2" };
29
+ const reason = inviteEmailFailureReason(leaky);
30
+ assert.match(reason, /refused Seven365's login/);
31
+ assert.ok(!reason.includes("hunter2"));
32
+ assert.match(inviteEmailFailureReason({ code: "EENVELOPE" }), /address was refused/);
33
+ assert.match(inviteEmailFailureReason({ responseCode: 550 }), /address was refused/);
34
+ assert.match(inviteEmailFailureReason({ code: "ETIMEDOUT" }), /did not answer/);
35
+ assert.match(inviteEmailFailureReason({ code: "ECONNECTION" }), /could not reach/);
36
+ assert.match(inviteEmailFailureReason({ responseCode: 421 }), /busy/);
37
+ assert.match(inviteEmailFailureReason({ code: "ETEMPLATE" }), /could not be prepared/);
38
+ assert.equal(inviteEmailFailureReason(new Error("x")), "the email could not be sent");
39
+ });
40
+
41
+ test("Resend waits 2 minutes after the last attempt", () => {
42
+ const now = new Date("2026-09-30T10:00:00Z");
43
+ assert.equal(inviteResendWaitMinutes([], now), 0);
44
+ assert.equal(inviteResendWaitMinutes([{ action: "email-sent", at: "2026-09-30T09:59:30Z" }], now), 2);
45
+ assert.equal(inviteResendWaitMinutes([{ action: "email-failed", at: "2026-09-30T09:58:30Z" }], now), 1);
46
+ assert.equal(inviteResendWaitMinutes([{ action: "email-sent", at: "2026-09-30T09:57:00Z" }], now), 0);
47
+ });
48
+
49
+ test("a new company's nature comes from the invited service; never property management", () => {
50
+ assert.equal(serviceProviderNatureFor("security_agency"), "security_agency");
51
+ assert.equal(serviceProviderNatureFor("Security"), "security_agency");
52
+ assert.equal(serviceProviderNatureFor("hygiene"), "cleaning_services");
53
+ assert.equal(serviceProviderNatureFor("pool"), "pool_maintenance_services");
54
+ assert.equal(serviceProviderNatureFor("property_management_agency"), "");
55
+ assert.equal(serviceProviderNatureFor("real_estate_developer"), "");
56
+ assert.equal(serviceProviderNatureFor(""), "");
57
+ assert.equal(serviceProviderNatureFor("rocket_science"), "");
58
+ });
59
+
60
+ test("a role-based email has no turn-off link and says why it was sent", () => {
61
+ const m = notificationEmail({
62
+ title: "Service provider invitation to approve",
63
+ body: "x",
64
+ moduleLabel: "Seven365 platform",
65
+ openUrl: "https://a/notifications/open?kind=spApproval",
66
+ settingsUrl: "https://a/notification-settings",
67
+ why: "You are a Seven365 administrator.",
68
+ });
69
+ assert.ok(!/Turn off email/.test(m.html) && !/Turn off email/.test(m.text));
70
+ assert.match(m.text, /You are a Seven365 administrator/);
71
+ // the opt-in email is unchanged
72
+ const optIn = notificationEmail({ title: "t", body: "b", moduleLabel: "M", openUrl: "o", offUrl: "off", settingsUrl: "s" });
73
+ assert.match(optIn.text, /Turn off email for M: off/);
74
+ });