@7365admin1/core 3.52.3 → 3.52.4

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.3",
4
+ "version": "3.52.4",
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 --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/utils/bulletin-service-apps.util.ts src/models/bulletin-board.model.ts src/utils/vehicle-import.util.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,87 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+
4
+ import {
5
+ BULLETIN_SERVICE_APPS,
6
+ serviceAppsForRecipients,
7
+ } from "./.build/utils/bulletin-service-apps.util.mjs";
8
+
9
+ test("each recipient group maps to exactly the app it always mapped to", () => {
10
+ assert.deepEqual(Object.fromEntries(BULLETIN_SERVICE_APPS.map((p) => [...p])), {
11
+ mechanical_electrical: "iservice365-m-and-e-mobile-app",
12
+ pest_control_services: "iservice365-pest-control-mobile-app",
13
+ pool_maintenance_services: "iservice365-pool-maintenance-mobile-app",
14
+ landscaping_services: "iservice365-landscaping-mobile-app",
15
+ cleaning_services: "iservice365-hygiene-mobile-app",
16
+ });
17
+ });
18
+
19
+ test("the order the five blocks ran in is preserved", () => {
20
+ assert.deepEqual(
21
+ serviceAppsForRecipients([
22
+ "cleaning_services",
23
+ "resident",
24
+ "mechanical_electrical",
25
+ "pest_control_services",
26
+ ]).map((x) => x.recipient),
27
+ ["mechanical_electrical", "pest_control_services", "cleaning_services"],
28
+ );
29
+ });
30
+
31
+ test("recipient groups with no service app are ignored, not mapped", () => {
32
+ assert.deepEqual(
33
+ serviceAppsForRecipients([
34
+ "resident",
35
+ "security_agency",
36
+ "property_management_agency",
37
+ ]),
38
+ [],
39
+ );
40
+ });
41
+
42
+ test("no recipients, or a non-array, is an empty list rather than a throw", () => {
43
+ assert.deepEqual(serviceAppsForRecipients([]), []);
44
+ assert.deepEqual(serviceAppsForRecipients(undefined), []);
45
+ assert.deepEqual(serviceAppsForRecipients(null), []);
46
+ });
47
+
48
+ test("selecting every recipient asks for all five apps", () => {
49
+ const all = [
50
+ "security_agency",
51
+ "cleaning_services",
52
+ "mechanical_electrical",
53
+ "property_management_agency",
54
+ "resident",
55
+ "pest_control_services",
56
+ "landscaping_services",
57
+ "pool_maintenance_services",
58
+ ];
59
+ assert.equal(serviceAppsForRecipients(all).length, 5);
60
+ });
61
+
62
+ /**
63
+ * The regression itself: the create path must reach its commit even when a
64
+ * service app has no registered users. This drives the same shape the service
65
+ * now uses — a loop that `continue`s — against the shape it used to use, and
66
+ * asserts only the first one commits.
67
+ */
68
+ test("an app with no push audience skips its notification, it does not abort the write", async () => {
69
+ const audiences = {
70
+ "iservice365-m-and-e-mobile-app": [],
71
+ "iservice365-pest-control-mobile-app": ["user-1"],
72
+ };
73
+ const notified = [];
74
+ let committed = false;
75
+
76
+ const recipients = ["mechanical_electrical", "pest_control_services"];
77
+
78
+ for (const { appSlug } of serviceAppsForRecipients(recipients)) {
79
+ const appUsers = audiences[appSlug] ?? [];
80
+ if (appUsers.length <= 0) continue;
81
+ notified.push(appSlug);
82
+ }
83
+ committed = true;
84
+
85
+ assert.equal(committed, true, "the bulletin must still be committed");
86
+ assert.deepEqual(notified, ["iservice365-pest-control-mobile-app"]);
87
+ });
@@ -0,0 +1,187 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+
4
+ import {
5
+ BulletinStatus,
6
+ deriveBulletinStatus,
7
+ expiredBulletinSweepFilter,
8
+ } from "./.build/models/bulletin-board.model.mjs";
9
+
10
+ // The live defect: created 2026-09-01T16:50Z to start at 22:00Z the SAME day.
11
+ // The old rule compared against the end of today, so this was `active` five
12
+ // hours early and the Upcoming tab was empty.
13
+ const now = new Date("2026-09-01T16:50:00.000Z");
14
+
15
+ test("a start date later today is upcoming, not active", () => {
16
+ assert.equal(
17
+ deriveBulletinStatus({ startDate: "2026-09-01T22:00:00.000Z" }, now),
18
+ BulletinStatus.UPCOMING,
19
+ );
20
+ });
21
+
22
+ test("a start date on a later day is upcoming", () => {
23
+ assert.equal(
24
+ deriveBulletinStatus({ startDate: "2026-09-05T09:00:00.000Z" }, now),
25
+ BulletinStatus.UPCOMING,
26
+ );
27
+ });
28
+
29
+ test("a start date in the past is active", () => {
30
+ assert.equal(
31
+ deriveBulletinStatus({ startDate: "2026-08-30T09:00:00.000Z" }, now),
32
+ BulletinStatus.ACTIVE,
33
+ );
34
+ });
35
+
36
+ test("a start date of exactly now is active", () => {
37
+ assert.equal(
38
+ deriveBulletinStatus({ startDate: now.toISOString() }, now),
39
+ BulletinStatus.ACTIVE,
40
+ );
41
+ });
42
+
43
+ // The product already has an `expired` state and a sweep that sets it
44
+ // (`processExpiredBulletinBoards`). An announcement whose window has already
45
+ // closed is therefore expired on arrival rather than published and then
46
+ // retracted minutes later by the sweep.
47
+ test("an already-ended announcement is expired", () => {
48
+ assert.equal(
49
+ deriveBulletinStatus(
50
+ {
51
+ startDate: "2026-08-01T09:00:00.000Z",
52
+ endDate: "2026-08-15T09:00:00.000Z",
53
+ noExpiration: false,
54
+ },
55
+ now,
56
+ ),
57
+ BulletinStatus.EXPIRED,
58
+ );
59
+ });
60
+
61
+ test("a past end date does not expire an announcement marked never-expiring", () => {
62
+ assert.equal(
63
+ deriveBulletinStatus(
64
+ {
65
+ startDate: "2026-08-01T09:00:00.000Z",
66
+ endDate: "2026-08-15T09:00:00.000Z",
67
+ noExpiration: true,
68
+ },
69
+ now,
70
+ ),
71
+ BulletinStatus.ACTIVE,
72
+ );
73
+ });
74
+
75
+ // A start date in the future wins over an end date in the past: the window is
76
+ // nonsense, and refusing to publish it is the safe reading of the two.
77
+ test("a future start beats a past end", () => {
78
+ assert.equal(
79
+ deriveBulletinStatus(
80
+ {
81
+ startDate: "2026-09-10T09:00:00.000Z",
82
+ endDate: "2026-08-15T09:00:00.000Z",
83
+ },
84
+ now,
85
+ ),
86
+ BulletinStatus.UPCOMING,
87
+ );
88
+ });
89
+
90
+ test("no start date derives nothing, so the caller keeps what it had", () => {
91
+ assert.equal(deriveBulletinStatus({}, now), undefined);
92
+ assert.equal(deriveBulletinStatus({ startDate: "" }, now), undefined);
93
+ });
94
+
95
+ // The sweep that activates upcoming rows uses `startDate <= now`. If this
96
+ // function used a different clock the two would fight: a row could be written
97
+ // `upcoming` and immediately be swept `active`, or the reverse.
98
+ test("agrees with the upcoming sweep's own boundary", () => {
99
+ const aMinuteAgo = new Date(now.getTime() - 60_000).toISOString();
100
+ const inAMinute = new Date(now.getTime() + 60_000).toISOString();
101
+
102
+ assert.equal(
103
+ deriveBulletinStatus({ startDate: aMinuteAgo }, now),
104
+ BulletinStatus.ACTIVE,
105
+ );
106
+ assert.equal(
107
+ deriveBulletinStatus({ startDate: inAMinute }, now),
108
+ BulletinStatus.UPCOMING,
109
+ );
110
+ });
111
+
112
+ // ---------------------------------------------------------------------------
113
+ // The expiry sweep must not touch a soft-deleted announcement.
114
+ //
115
+ // A soft delete only rewrites `status` and `deletedAt`; the row keeps its
116
+ // dates. The sweep matched on the dates alone, so it overwrote
117
+ // `status: "deleted"` with `status: "expired"` and the announcement came back
118
+ // on the Expired tab. Measured on staging: 30 of 63 deleted rows (48%).
119
+
120
+ // Only `$lt` and `$ne` are used by the filter, so that is all this needs.
121
+ const matches = (filter, doc) =>
122
+ Object.entries(filter).every(([key, want]) => {
123
+ const got = doc[key];
124
+ if (want !== null && typeof want === "object") {
125
+ if ("$lt" in want) return got !== undefined && got < want.$lt;
126
+ if ("$ne" in want) return got !== want.$ne;
127
+ throw new Error(`unhandled operator in ${key}`);
128
+ }
129
+ return got === want;
130
+ });
131
+
132
+ const sweptAt = new Date("2026-09-02T10:00:00.000Z");
133
+ const filter = expiredBulletinSweepFilter(sweptAt);
134
+
135
+ const endedYesterday = {
136
+ noExpiration: false,
137
+ endDate: "2026-09-01T10:00:00.000Z",
138
+ };
139
+
140
+ test("a soft-deleted bulletin is not swept to expired", () => {
141
+ assert.equal(
142
+ matches(filter, { ...endedYesterday, status: BulletinStatus.DELETED }),
143
+ false,
144
+ );
145
+ });
146
+
147
+ test("a live bulletin past its end date is still swept", () => {
148
+ assert.equal(
149
+ matches(filter, { ...endedYesterday, status: BulletinStatus.ACTIVE }),
150
+ true,
151
+ );
152
+ });
153
+
154
+ test("a bulletin that has not ended yet is left alone", () => {
155
+ assert.equal(
156
+ matches(filter, {
157
+ noExpiration: false,
158
+ endDate: "2026-09-03T10:00:00.000Z",
159
+ status: BulletinStatus.ACTIVE,
160
+ }),
161
+ false,
162
+ );
163
+ });
164
+
165
+ test("a never-expiring bulletin is left alone", () => {
166
+ assert.equal(
167
+ matches(filter, {
168
+ ...endedYesterday,
169
+ noExpiration: true,
170
+ status: BulletinStatus.ACTIVE,
171
+ }),
172
+ false,
173
+ );
174
+ });
175
+
176
+ // The status the sweep writes has to be the one `deriveBulletinStatus` would
177
+ // derive for the same row, or create/update and the sweep disagree again.
178
+ test("the sweep and deriveBulletinStatus agree on the same row", () => {
179
+ const row = {
180
+ startDate: "2026-08-20T10:00:00.000Z",
181
+ endDate: "2026-09-01T10:00:00.000Z",
182
+ noExpiration: false,
183
+ };
184
+
185
+ assert.equal(matches(filter, { ...row, status: BulletinStatus.ACTIVE }), true);
186
+ assert.equal(deriveBulletinStatus(row, sweptAt), BulletinStatus.EXPIRED);
187
+ });
@@ -0,0 +1,84 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+
4
+ import {
5
+ SITE_TIME_ZONE,
6
+ formatExpiryForDisplay,
7
+ parseExpiryDate,
8
+ } from "./.build/utils/vehicle-import.util.mjs";
9
+
10
+ // The whole point of the fix is that the answer does NOT depend on the process
11
+ // zone: the API container runs UTC, a developer's machine may not, and both
12
+ // must store the same instant. Every assertion below is an absolute instant,
13
+ // so this file is meaningful in any zone.
14
+ test("the site zone is the one the rest of the server already assumes", () => {
15
+ assert.equal(SITE_TIME_ZONE, "Asia/Singapore");
16
+ });
17
+
18
+ /* -------------------------------------------------------------------------- */
19
+ /* The round trip: what the sheet says is what the screen says */
20
+ /* -------------------------------------------------------------------------- */
21
+
22
+ // Each case is [what the cell holds, the wall clock it means].
23
+ // A Date is what the row already looks like by the time the controller sees it:
24
+ // `Joi.date().iso()` converts before we get the text, and ExcelJS hands back a
25
+ // Date for a date-formatted cell. Both flatten the wall clock onto UTC.
26
+ const CELLS = [
27
+ ["2026-12-05", "2026-12-05T00:00"],
28
+ ["2026-12-05T18:45", "2026-12-05T18:45"],
29
+ ["5-Dec-26", "2026-12-05T00:00"],
30
+ ["05-Dec-26", "2026-12-05T00:00"],
31
+ [new Date("2026-12-05T00:00:00.000Z"), "2026-12-05T00:00"],
32
+ [new Date("2026-12-05T18:45:00.000Z"), "2026-12-05T18:45"],
33
+ ];
34
+
35
+ for (const [cell, wallClock] of CELLS) {
36
+ const label = cell instanceof Date ? `Date(${cell.toISOString()})` : cell;
37
+ test(`${label} comes back as ${wallClock}`, () => {
38
+ const stored = parseExpiryDate(cell);
39
+ assert.ok(stored, "the cell should parse");
40
+ assert.equal(formatExpiryForDisplay(stored), wallClock);
41
+ });
42
+ }
43
+
44
+ /* -------------------------------------------------------------------------- */
45
+ /* The defect itself, pinned as an instant */
46
+ /* -------------------------------------------------------------------------- */
47
+
48
+ test("a date-only cell stores the Singapore midnight, not the UTC one", () => {
49
+ // The old code stored 2026-12-05T00:00:00.000Z, which reads as 8am on screen.
50
+ assert.equal(parseExpiryDate("2026-12-05"), "2026-12-04T16:00:00.000Z");
51
+ });
52
+
53
+ test("a cell with a time no longer rolls into the next day", () => {
54
+ // The old code stored 18:45Z, which reads as 02:45 on the 6th in Singapore.
55
+ const stored = parseExpiryDate("2026-12-05T18:45");
56
+ assert.equal(stored, "2026-12-05T10:45:00.000Z");
57
+ assert.ok(
58
+ formatExpiryForDisplay(stored).startsWith("2026-12-05"),
59
+ "must stay on the 5th",
60
+ );
61
+ });
62
+
63
+ test("the template format and the ISO format agree", () => {
64
+ assert.equal(parseExpiryDate("5-Dec-26"), parseExpiryDate("2026-12-05"));
65
+ });
66
+
67
+ /* -------------------------------------------------------------------------- */
68
+ /* The paths that must not change */
69
+ /* -------------------------------------------------------------------------- */
70
+
71
+ test("an empty cell still falls back to ten years out", () => {
72
+ for (const empty of [undefined, null, "", " "]) {
73
+ const stored = parseExpiryDate(empty);
74
+ assert.ok(stored, `${JSON.stringify(empty)} should fall back`);
75
+ const years =
76
+ (new Date(stored).getTime() - Date.now()) / (365.25 * 24 * 3600 * 1000);
77
+ assert.ok(years > 9.9 && years < 10.1, `got ${years} years`);
78
+ }
79
+ });
80
+
81
+ test("an unreadable cell is still rejected, not guessed at", () => {
82
+ assert.equal(parseExpiryDate("5-Foo-26"), undefined);
83
+ assert.equal(parseExpiryDate("not a date"), undefined);
84
+ });
@@ -0,0 +1,77 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+
4
+ import { schemaInviteVisitor } from "./.build/models/visitor-invite.model.mjs";
5
+
6
+ const INVITER = "6a7c0a4c850a58d1a7e9ae1b";
7
+
8
+ /** What an already-installed resident app build sends: no `type` at all. */
9
+ function guestBodyWithoutType() {
10
+ return {
11
+ inviterUserId: INVITER,
12
+ expectedCheckIn: "2026-09-01T14:01:00.000Z",
13
+ arrivalTime: "22:01",
14
+ name: "Harris",
15
+ contact: "",
16
+ email: "",
17
+ plateNumber: "",
18
+ purpose: "",
19
+ duration: 0,
20
+ numberOfPassengers: 0,
21
+ isOvernightParking: false,
22
+ };
23
+ }
24
+
25
+ test("an invite with no `type` is accepted and files as a guest", () => {
26
+ const { error, value } = schemaInviteVisitor.validate(guestBodyWithoutType(), {
27
+ abortEarly: false,
28
+ });
29
+
30
+ assert.equal(error, undefined, error && error.message);
31
+ assert.equal(value.type, "guest");
32
+ });
33
+
34
+ test("the empty Duration field the form leaves blank is still accepted", () => {
35
+ const body = { ...guestBodyWithoutType(), duration: "" };
36
+ const { error } = schemaInviteVisitor.validate(body, { abortEarly: false });
37
+
38
+ assert.equal(error, undefined, error && error.message);
39
+ });
40
+
41
+ test("a contractor invite still has to name its contractorType", () => {
42
+ const body = { ...guestBodyWithoutType(), type: "contractor" };
43
+ const { error } = schemaInviteVisitor.validate(body, { abortEarly: false });
44
+
45
+ assert.ok(error, "contractor without contractorType must be refused");
46
+ assert.match(error.message, /contractorType/);
47
+ });
48
+
49
+ test("a contractor invite that names its contractorType passes unchanged", () => {
50
+ const body = {
51
+ ...guestBodyWithoutType(),
52
+ type: "contractor",
53
+ contractorType: "house-mover",
54
+ };
55
+ const { error, value } = schemaInviteVisitor.validate(body, {
56
+ abortEarly: false,
57
+ });
58
+
59
+ assert.equal(error, undefined, error && error.message);
60
+ assert.equal(value.type, "contractor");
61
+ });
62
+
63
+ test("an unrecognised type is still refused rather than defaulted away", () => {
64
+ const body = { ...guestBodyWithoutType(), type: "walk-in" };
65
+ const { error } = schemaInviteVisitor.validate(body, { abortEarly: false });
66
+
67
+ assert.ok(error, "an unknown type must not be silently accepted");
68
+ });
69
+
70
+ test("name and expectedCheckIn are still required", () => {
71
+ for (const field of ["name", "expectedCheckIn"]) {
72
+ const body = guestBodyWithoutType();
73
+ delete body[field];
74
+ const { error } = schemaInviteVisitor.validate(body, { abortEarly: false });
75
+ assert.ok(error, `${field} must still be required`);
76
+ }
77
+ });