@7365admin1/core 3.52.7 → 3.52.8
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/CHANGELOG.md +80 -0
- package/dist/index.d.ts +122 -2
- package/dist/index.js +226 -78
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +236 -93
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
- package/test/camera-view.util.test.mjs +168 -0
- package/test/site-timezone.test.mjs +69 -0
- package/test/visitor-invite-schema.test.mjs +41 -0
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@7365admin1/core",
|
|
3
3
|
"license": "MIT",
|
|
4
|
-
"version": "3.52.
|
|
4
|
+
"version": "3.52.8",
|
|
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/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 --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/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 --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
|
},
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
hasAnyPermission,
|
|
14
14
|
isCameraEntitled,
|
|
15
15
|
cameraGrant,
|
|
16
|
+
orgSiteScope,
|
|
16
17
|
parseDeviceTime,
|
|
17
18
|
parseSoftwareVersion,
|
|
18
19
|
ptzEndpoint,
|
|
@@ -934,3 +935,170 @@ test("a write is scoped to the caller's own sites, not just to any signed-in use
|
|
|
934
935
|
// Signed in, but a member of nothing.
|
|
935
936
|
assert.equal(isCameraEntitled({ cameraSite: site, memberships: [] }), false);
|
|
936
937
|
});
|
|
938
|
+
|
|
939
|
+
/*
|
|
940
|
+
* `orgSiteScope` — which sites of ONE organisation a caller may be OFFERED.
|
|
941
|
+
*
|
|
942
|
+
* The listing half of `cameraGrant`. `GET /api/customer-sites?org=` authorised
|
|
943
|
+
* at organisation level and then returned the organisation's whole estate, so a
|
|
944
|
+
* user invited to a single site saw every site in the client in the switcher.
|
|
945
|
+
*/
|
|
946
|
+
const SCOPE_ORG = "aaaaaaaaaaaaaaaaaaaaaaaa";
|
|
947
|
+
const SCOPE_OTHER_ORG = "bbbbbbbbbbbbbbbbbbbbbbbb";
|
|
948
|
+
const SCOPE_SITE_A = "cccccccccccccccccccccccc";
|
|
949
|
+
const SCOPE_SITE_B = "dddddddddddddddddddddddd";
|
|
950
|
+
|
|
951
|
+
test("orgSiteScope: an org-wide membership keeps the whole list", () => {
|
|
952
|
+
// The half a careless narrowing breaks. An org owner/admin holds a membership
|
|
953
|
+
// with no site, and must still see every site in their organisation.
|
|
954
|
+
assert.equal(
|
|
955
|
+
orgSiteScope({ memberships: [{ org: SCOPE_ORG, siteId: "" }], org: SCOPE_ORG }),
|
|
956
|
+
null,
|
|
957
|
+
);
|
|
958
|
+
assert.equal(
|
|
959
|
+
orgSiteScope({ memberships: [{ org: SCOPE_ORG }], org: SCOPE_ORG }),
|
|
960
|
+
null,
|
|
961
|
+
);
|
|
962
|
+
// One org-wide membership wins even alongside pinned ones.
|
|
963
|
+
assert.equal(
|
|
964
|
+
orgSiteScope({
|
|
965
|
+
memberships: [
|
|
966
|
+
{ org: SCOPE_ORG, siteId: SCOPE_SITE_A },
|
|
967
|
+
{ org: SCOPE_ORG, siteId: "" },
|
|
968
|
+
],
|
|
969
|
+
org: SCOPE_ORG,
|
|
970
|
+
}),
|
|
971
|
+
null,
|
|
972
|
+
);
|
|
973
|
+
});
|
|
974
|
+
|
|
975
|
+
test("orgSiteScope: a site-pinned membership is offered that site alone", () => {
|
|
976
|
+
assert.deepEqual(
|
|
977
|
+
orgSiteScope({ memberships: [{ org: SCOPE_ORG, siteId: SCOPE_SITE_A }], org: SCOPE_ORG }),
|
|
978
|
+
[SCOPE_SITE_A],
|
|
979
|
+
);
|
|
980
|
+
// Two pins in the same organisation are both offered, de-duplicated.
|
|
981
|
+
assert.deepEqual(
|
|
982
|
+
orgSiteScope({
|
|
983
|
+
memberships: [
|
|
984
|
+
{ org: SCOPE_ORG, siteId: SCOPE_SITE_A },
|
|
985
|
+
{ org: SCOPE_ORG, siteId: SCOPE_SITE_B },
|
|
986
|
+
{ org: SCOPE_ORG, siteId: SCOPE_SITE_A },
|
|
987
|
+
],
|
|
988
|
+
org: SCOPE_ORG,
|
|
989
|
+
}),
|
|
990
|
+
[SCOPE_SITE_A, SCOPE_SITE_B],
|
|
991
|
+
);
|
|
992
|
+
});
|
|
993
|
+
|
|
994
|
+
test("orgSiteScope: another organisation's memberships do not widen this one", () => {
|
|
995
|
+
// The reported defect in one line: pinned to one site here, org-wide
|
|
996
|
+
// somewhere else. The org-wide row must not unlock this organisation.
|
|
997
|
+
assert.deepEqual(
|
|
998
|
+
orgSiteScope({
|
|
999
|
+
memberships: [
|
|
1000
|
+
{ org: SCOPE_ORG, siteId: SCOPE_SITE_A },
|
|
1001
|
+
{ org: SCOPE_OTHER_ORG, siteId: "" },
|
|
1002
|
+
],
|
|
1003
|
+
org: SCOPE_ORG,
|
|
1004
|
+
}),
|
|
1005
|
+
[SCOPE_SITE_A],
|
|
1006
|
+
);
|
|
1007
|
+
// No membership in this organisation at all: fail closed, not "show all".
|
|
1008
|
+
assert.deepEqual(
|
|
1009
|
+
orgSiteScope({ memberships: [{ org: SCOPE_OTHER_ORG, siteId: "" }], org: SCOPE_ORG }),
|
|
1010
|
+
[],
|
|
1011
|
+
);
|
|
1012
|
+
});
|
|
1013
|
+
|
|
1014
|
+
test("orgSiteScope: ObjectId and hex-string ids are the same id", () => {
|
|
1015
|
+
assert.deepEqual(
|
|
1016
|
+
orgSiteScope({
|
|
1017
|
+
memberships: [{ org: { toString: () => SCOPE_ORG }, siteId: { toString: () => SCOPE_SITE_A } }],
|
|
1018
|
+
org: SCOPE_ORG,
|
|
1019
|
+
}),
|
|
1020
|
+
[SCOPE_SITE_A],
|
|
1021
|
+
);
|
|
1022
|
+
});
|
|
1023
|
+
|
|
1024
|
+
test("orgSiteScope: no organisation asked about means no restriction", () => {
|
|
1025
|
+
assert.equal(orgSiteScope({ memberships: [{ org: SCOPE_ORG, siteId: SCOPE_SITE_A }] }), null);
|
|
1026
|
+
});
|
|
1027
|
+
|
|
1028
|
+
/**
|
|
1029
|
+
* The owner's rule of 2026-09-02: "Org-level roles see all sites."
|
|
1030
|
+
*
|
|
1031
|
+
* The first cut of this change keyed only on the membership's `siteId`, which
|
|
1032
|
+
* narrowed 95 `(user, org)` pairs on staging whose role is organisation-level
|
|
1033
|
+
* (`Org Owner` in most cases) but whose membership row also happens to name a
|
|
1034
|
+
* site. These three tests are the three shapes the rule has to get right.
|
|
1035
|
+
*/
|
|
1036
|
+
test("orgSiteScope: a site-LEVEL role pinned to a site sees only that site", () => {
|
|
1037
|
+
assert.deepEqual(
|
|
1038
|
+
orgSiteScope({
|
|
1039
|
+
memberships: [
|
|
1040
|
+
{ org: SCOPE_ORG, siteId: SCOPE_SITE_A, orgLevelRole: false },
|
|
1041
|
+
],
|
|
1042
|
+
org: SCOPE_ORG,
|
|
1043
|
+
}),
|
|
1044
|
+
[SCOPE_SITE_A],
|
|
1045
|
+
);
|
|
1046
|
+
});
|
|
1047
|
+
|
|
1048
|
+
test("orgSiteScope: an ORG-level role carrying a siteId still sees every site", () => {
|
|
1049
|
+
assert.equal(
|
|
1050
|
+
orgSiteScope({
|
|
1051
|
+
memberships: [
|
|
1052
|
+
{ org: SCOPE_ORG, siteId: SCOPE_SITE_A, orgLevelRole: true },
|
|
1053
|
+
],
|
|
1054
|
+
org: SCOPE_ORG,
|
|
1055
|
+
}),
|
|
1056
|
+
null,
|
|
1057
|
+
);
|
|
1058
|
+
|
|
1059
|
+
// and it widens the whole organisation, not just its own row
|
|
1060
|
+
assert.equal(
|
|
1061
|
+
orgSiteScope({
|
|
1062
|
+
memberships: [
|
|
1063
|
+
{ org: SCOPE_ORG, siteId: SCOPE_SITE_A, orgLevelRole: false },
|
|
1064
|
+
{ org: SCOPE_ORG, siteId: SCOPE_SITE_B, orgLevelRole: true },
|
|
1065
|
+
],
|
|
1066
|
+
org: SCOPE_ORG,
|
|
1067
|
+
}),
|
|
1068
|
+
null,
|
|
1069
|
+
);
|
|
1070
|
+
});
|
|
1071
|
+
|
|
1072
|
+
test("orgSiteScope: an ORG-level role with no siteId sees every site", () => {
|
|
1073
|
+
assert.equal(
|
|
1074
|
+
orgSiteScope({
|
|
1075
|
+
memberships: [{ org: SCOPE_ORG, siteId: "", orgLevelRole: true }],
|
|
1076
|
+
org: SCOPE_ORG,
|
|
1077
|
+
}),
|
|
1078
|
+
null,
|
|
1079
|
+
);
|
|
1080
|
+
});
|
|
1081
|
+
|
|
1082
|
+
test("orgSiteScope: an org-level role in ANOTHER organisation does not widen this one", () => {
|
|
1083
|
+
assert.deepEqual(
|
|
1084
|
+
orgSiteScope({
|
|
1085
|
+
memberships: [
|
|
1086
|
+
{ org: SCOPE_ORG, siteId: SCOPE_SITE_A, orgLevelRole: false },
|
|
1087
|
+
{ org: SCOPE_OTHER_ORG, siteId: "", orgLevelRole: true },
|
|
1088
|
+
],
|
|
1089
|
+
org: SCOPE_ORG,
|
|
1090
|
+
}),
|
|
1091
|
+
[SCOPE_SITE_A],
|
|
1092
|
+
);
|
|
1093
|
+
});
|
|
1094
|
+
|
|
1095
|
+
test("orgSiteScope: an unresolved role fails closed — it does not widen", () => {
|
|
1096
|
+
// `orgLevelRole` absent is how a missing or soft-deleted role arrives.
|
|
1097
|
+
assert.deepEqual(
|
|
1098
|
+
orgSiteScope({
|
|
1099
|
+
memberships: [{ org: SCOPE_ORG, siteId: SCOPE_SITE_A }],
|
|
1100
|
+
org: SCOPE_ORG,
|
|
1101
|
+
}),
|
|
1102
|
+
[SCOPE_SITE_A],
|
|
1103
|
+
);
|
|
1104
|
+
});
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
DEFAULT_SITE_TIMEZONE,
|
|
6
|
+
resolveSiteTimezone,
|
|
7
|
+
siteDayBounds,
|
|
8
|
+
} from "./.build/utils/site-timezone.util.mjs";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* A visitor invitation is valid for one calendar day in the SITE's timezone —
|
|
12
|
+
* 00:00:00.000 through 23:59:59.999 — never a rolling 24 hours from whatever
|
|
13
|
+
* instant happened to be stored.
|
|
14
|
+
*/
|
|
15
|
+
test("a bare date maps onto exact site-local day boundaries", () => {
|
|
16
|
+
const day = siteDayBounds("2026-09-02", "Asia/Singapore");
|
|
17
|
+
|
|
18
|
+
assert.equal(day.start.toISOString(), "2026-09-01T16:00:00.000Z");
|
|
19
|
+
assert.equal(day.end.toISOString(), "2026-09-02T15:59:59.999Z");
|
|
20
|
+
assert.equal(day.date, "2026-09-02");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("the day is recovered from a legacy expectedCheckIn instant", () => {
|
|
24
|
+
// What the mobile app writes today: midday on the resident's own device.
|
|
25
|
+
const day = siteDayBounds(new Date("2026-09-02T04:00:00.000Z"), "Asia/Singapore");
|
|
26
|
+
|
|
27
|
+
assert.equal(day.date, "2026-09-02");
|
|
28
|
+
assert.equal(day.start.toISOString(), "2026-09-01T16:00:00.000Z");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("arrivalDate holds the day when the inviter is in a distant zone", () => {
|
|
32
|
+
// A resident in UTC-5 picking the 2nd pins midday THERE: 17:00Z. Read back in
|
|
33
|
+
// the site's zone that instant is already the 3rd — which is exactly why the
|
|
34
|
+
// bare date is preferred over the instant.
|
|
35
|
+
assert.equal(siteDayBounds("2026-09-02", "Asia/Singapore").date, "2026-09-02");
|
|
36
|
+
assert.equal(
|
|
37
|
+
siteDayBounds(new Date("2026-09-02T17:00:00.000Z"), "Asia/Singapore").date,
|
|
38
|
+
"2026-09-03",
|
|
39
|
+
);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("a DST transition day is bounded by its real local edges", () => {
|
|
43
|
+
// 2026-03-08 is the US spring-forward: a 23-hour calendar day.
|
|
44
|
+
const day = siteDayBounds("2026-03-08", "America/New_York");
|
|
45
|
+
|
|
46
|
+
assert.equal(day.start.toISOString(), "2026-03-08T05:00:00.000Z");
|
|
47
|
+
assert.equal(day.end.toISOString(), "2026-03-09T03:59:59.999Z");
|
|
48
|
+
assert.equal((day.end.getTime() - day.start.getTime() + 1) / 3_600_000, 23);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("unusable input yields null rather than a bogus window", () => {
|
|
52
|
+
assert.equal(siteDayBounds(null, "Asia/Singapore"), null);
|
|
53
|
+
assert.equal(siteDayBounds(undefined, "Asia/Singapore"), null);
|
|
54
|
+
assert.equal(siteDayBounds("not-a-date", "Asia/Singapore"), null);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("an unusable site timezone falls back to the default", () => {
|
|
58
|
+
assert.equal(
|
|
59
|
+
siteDayBounds("2026-09-02", "Mars/Olympus").start.toISOString(),
|
|
60
|
+
"2026-09-01T16:00:00.000Z",
|
|
61
|
+
);
|
|
62
|
+
assert.equal(resolveSiteTimezone(null), DEFAULT_SITE_TIMEZONE);
|
|
63
|
+
assert.equal(resolveSiteTimezone({}), DEFAULT_SITE_TIMEZONE);
|
|
64
|
+
assert.equal(resolveSiteTimezone({ timezone: "nonsense" }), DEFAULT_SITE_TIMEZONE);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("a valid site timezone is used as given", () => {
|
|
68
|
+
assert.equal(resolveSiteTimezone({ timezone: "Asia/Manila" }), "Asia/Manila");
|
|
69
|
+
});
|
|
@@ -75,3 +75,44 @@ test("name and expectedCheckIn are still required", () => {
|
|
|
75
75
|
assert.ok(error, `${field} must still be required`);
|
|
76
76
|
}
|
|
77
77
|
});
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* `arrivalDate` — the calendar day the invitation is valid for.
|
|
81
|
+
*
|
|
82
|
+
* It is deliberately OPTIONAL. Builds already in the stores send only
|
|
83
|
+
* `expectedCheckIn`, and the invite service derives the day from it in the
|
|
84
|
+
* site's zone; requiring the field here would 400 every one of those users on
|
|
85
|
+
* a form that never showed them the field, the same trap `type` fell into.
|
|
86
|
+
*/
|
|
87
|
+
test("a bare YYYY-MM-DD arrivalDate is accepted and preserved", () => {
|
|
88
|
+
const body = { ...guestBodyWithoutType(), arrivalDate: "2026-09-02" };
|
|
89
|
+
const { error, value } = schemaInviteVisitor.validate(body, {
|
|
90
|
+
abortEarly: false,
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
assert.equal(error, undefined, error && error.message);
|
|
94
|
+
assert.equal(value.arrivalDate, "2026-09-02");
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("an invite without arrivalDate is still accepted", () => {
|
|
98
|
+
const { error } = schemaInviteVisitor.validate(guestBodyWithoutType(), {
|
|
99
|
+
abortEarly: false,
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
assert.equal(error, undefined, error && error.message);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("arrivalDate refuses anything that is not a bare calendar date", () => {
|
|
106
|
+
for (const bad of [
|
|
107
|
+
"02/09/2026",
|
|
108
|
+
"2026-9-2",
|
|
109
|
+
"2026-09-02T04:00:00.000Z",
|
|
110
|
+
"tomorrow",
|
|
111
|
+
]) {
|
|
112
|
+
const body = { ...guestBodyWithoutType(), arrivalDate: bad };
|
|
113
|
+
const { error } = schemaInviteVisitor.validate(body, { abortEarly: false });
|
|
114
|
+
|
|
115
|
+
assert.ok(error, `${bad} must not be accepted as an arrivalDate`);
|
|
116
|
+
assert.match(error.message, /arrivalDate must be in YYYY-MM-DD format/);
|
|
117
|
+
}
|
|
118
|
+
});
|