@7365admin1/core 3.32.2-staging.77 → 3.32.2-staging.79

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.
@@ -0,0 +1,26 @@
1
+ ---
2
+ "@7365admin1/core": patch
3
+ ---
4
+
5
+ Stop rejecting site names that only differ by their building number
6
+
7
+ Adding a site refused any name within a Levenshtein distance of 2 of an
8
+ existing site in the same organisation, and told the user to contact support.
9
+ That is exactly one character, so "Winsland House II" could not be added next
10
+ to "Winsland House I". The same rule blocked "Tower A" beside "Tower B",
11
+ "Phase 2" beside "Phase 1" and "Block 15" beside "Block 5" — the standard way
12
+ buildings are named here.
13
+
14
+ The check now treats trailing numbers, Roman numerals and single letters as the
15
+ part that tells two buildings apart: if they differ, the names are different
16
+ sites and the distance is never measured. Real duplicates are still refused —
17
+ an exact repeat, a different capitalisation, stray or doubled whitespace,
18
+ punctuation-only differences, and a one or two character typo within the same
19
+ building. "House 1" and "House I" are still read as the same building.
20
+
21
+ The refusal now names the site it matched and says what to do about it instead
22
+ of pointing the user at support.
23
+
24
+ `site.repo.getByExactName` also built its case-insensitive regex from the raw
25
+ name; a name containing regex characters either threw or matched a site it is
26
+ not. It is escaped.
package/dist/index.d.ts CHANGED
@@ -9209,7 +9209,7 @@ type HidObjectBatch = {
9209
9209
  };
9210
9210
  type HidObjectQuery = {
9211
9211
  object: string;
9212
- where?: Record<string, any>;
9212
+ where?: UnknownRecord;
9213
9213
  fields?: string[];
9214
9214
  order?: string[];
9215
9215
  limit?: number;
@@ -9241,14 +9241,19 @@ declare function useHidAmicoService(): {
9241
9241
  message: string;
9242
9242
  results: Record<string, any>[];
9243
9243
  }>;
9244
- receiveNotification: (readerId: string, type: string, payload: Record<string, any>) => Promise<{
9245
- _id: bson.ObjectId;
9246
- reader: bson.ObjectId;
9247
- site: bson.ObjectId | undefined;
9248
- type: string;
9249
- payload: Record<string, any>;
9250
- status: "failed" | "received" | "processed";
9251
- createdAt: string | Date;
9244
+ receiveNotification: (readerId: string, type: string, payload: UnknownRecord) => Promise<{
9245
+ event: {
9246
+ _id: bson.ObjectId;
9247
+ reader: bson.ObjectId;
9248
+ site: bson.ObjectId | undefined;
9249
+ type: string;
9250
+ payload: Record<string, any>;
9251
+ status: "failed" | "received" | "processed";
9252
+ createdAt: string | Date;
9253
+ };
9254
+ response: {
9255
+ result: UnknownRecord;
9256
+ } | null;
9252
9257
  }>;
9253
9258
  listLogs: (value: {
9254
9259
  reader: string;
package/dist/index.js CHANGED
@@ -11811,8 +11811,9 @@ function useSiteRepo() {
11811
11811
  } catch (error2) {
11812
11812
  throw new import_node_server_utils19.BadRequestError("Invalid org ID format.");
11813
11813
  }
11814
+ const escapedName = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
11814
11815
  const query2 = {
11815
- name: { $regex: new RegExp(`^${name}$`, "i") },
11816
+ name: { $regex: new RegExp(`^${escapedName}$`, "i") },
11816
11817
  // Case-insensitive exact match
11817
11818
  orgId,
11818
11819
  status: { $ne: "deleted" }
@@ -35083,7 +35084,57 @@ function useCustomerSiteRepo() {
35083
35084
 
35084
35085
  // src/services/customer-site.service.ts
35085
35086
  var import_node_server_utils98 = require("@7365admin1/node-server-utils");
35087
+
35088
+ // src/utils/site-name.util.ts
35086
35089
  var import_fast_levenshtein = __toESM(require("fast-levenshtein"));
35090
+ var ROMAN = /^(?=[ivx])(x{0,3})(ix|iv|v?i{0,3})$/;
35091
+ var ROMAN_VALUE = { i: 1, v: 5, x: 10 };
35092
+ function normalizeSiteName(name) {
35093
+ return String(name ?? "").normalize("NFKC").toLowerCase().replace(/[‘’']/g, "").replace(/[^a-z0-9]+/g, " ").trim();
35094
+ }
35095
+ function romanToNumber(token) {
35096
+ let total = 0;
35097
+ for (let i = 0; i < token.length; i++) {
35098
+ const value = ROMAN_VALUE[token[i]];
35099
+ const next = ROMAN_VALUE[token[i + 1]];
35100
+ total += next && next > value ? -value : value;
35101
+ }
35102
+ return total;
35103
+ }
35104
+ function distinguishingTokens(normalized) {
35105
+ if (!normalized)
35106
+ return [];
35107
+ return normalized.split(" ").filter((t) => /^\d+$/.test(t) || ROMAN.test(t) || /^[a-z]$/.test(t)).map((t) => ROMAN.test(t) ? String(romanToNumber(t)) : t);
35108
+ }
35109
+ function matchSiteName(candidate, existing) {
35110
+ const a = normalizeSiteName(candidate);
35111
+ const b = normalizeSiteName(existing);
35112
+ if (!a || !b)
35113
+ return null;
35114
+ if (a === b)
35115
+ return "duplicate";
35116
+ const tokensA = distinguishingTokens(a).join(" ");
35117
+ const tokensB = distinguishingTokens(b).join(" ");
35118
+ if (tokensA !== tokensB)
35119
+ return null;
35120
+ return import_fast_levenshtein.default.get(a, b) <= 2 ? "near-duplicate" : null;
35121
+ }
35122
+ function findSiteNameClash(candidate, existingNames) {
35123
+ for (const name of existingNames) {
35124
+ const match = matchSiteName(candidate, name);
35125
+ if (match)
35126
+ return { name, match };
35127
+ }
35128
+ return null;
35129
+ }
35130
+ function siteNameClashMessage(existingName, match) {
35131
+ if (match === "duplicate") {
35132
+ return `A site called "${existingName}" already exists in this organisation. Give the new site a different name, or edit "${existingName}" instead.`;
35133
+ }
35134
+ return `This name is within a character or two of "${existingName}", which already exists in this organisation. If it is the same property, edit "${existingName}" instead. If it is a different one, include what tells them apart \u2014 the block, tower, phase, or number \u2014 and save again.`;
35135
+ }
35136
+
35137
+ // src/services/customer-site.service.ts
35087
35138
  function useCustomerSiteService() {
35088
35139
  const { add: _add, updateCustomerSiteById, getById: _getById } = useCustomerSiteRepo();
35089
35140
  const {
@@ -35105,20 +35156,18 @@ function useCustomerSiteService() {
35105
35156
  const exactMatches = await getSiteByExactName(value.name, value.siteOrg);
35106
35157
  if (exactMatches && exactMatches.length > 0) {
35107
35158
  throw new import_node_server_utils98.BadRequestError(
35108
- "Site with this name already exists in the organization."
35159
+ siteNameClashMessage(exactMatches[0].name, "duplicate")
35109
35160
  );
35110
35161
  }
35111
- const threshold = 2;
35112
35162
  const sites = await getSiteByName(value.name);
35113
- if (sites && sites.length > 0) {
35114
- const similar = sites.filter((doc) => {
35115
- return doc.orgId && doc.name && doc.orgId.toString() === value.siteOrg.toString() && import_fast_levenshtein.default.get(value.name.toLowerCase(), doc.name.toLowerCase()) <= threshold;
35116
- });
35117
- if (similar.length > 0) {
35118
- throw new import_node_server_utils98.BadRequestError(
35119
- "Failed to add site, site with closely similar name found. Please contact seven365 support."
35120
- );
35121
- }
35163
+ const candidates = (sites ?? []).filter(
35164
+ (doc) => doc.orgId && doc.name && doc.orgId.toString() === value.siteOrg.toString()
35165
+ ).map((doc) => doc.name);
35166
+ const clash = findSiteNameClash(value.name, candidates);
35167
+ if (clash) {
35168
+ throw new import_node_server_utils98.BadRequestError(
35169
+ siteNameClashMessage(clash.name, clash.match)
35170
+ );
35122
35171
  }
35123
35172
  const siteId = await createSite(
35124
35173
  {
@@ -74389,6 +74438,8 @@ var schemaHidAmicoNotificationParams = import_joi153.default.object({
74389
74438
  "secbox",
74390
74439
  "access_photo",
74391
74440
  "new_user_identified",
74441
+ "new_qrcode",
74442
+ "new_card",
74392
74443
  "push_result"
74393
74444
  ).required()
74394
74445
  });
@@ -75220,6 +75271,8 @@ async function configureVisitorQrReader(client, qrFormat) {
75220
75271
  }
75221
75272
  async function ensureVisitorAccessAuthorization(client, readerId, readerLocation, hidUserId) {
75222
75273
  const accessRuleId = createStableHidId(`visitor-access-rule:${readerId}`);
75274
+ const timeZoneId = createStableHidId(`visitor-time-zone:${readerId}`);
75275
+ const timeSpanId = createStableHidId(`visitor-time-span:${readerId}`);
75223
75276
  const existingRuleResponse = await client.loadObjects({
75224
75277
  object: "access_rules",
75225
75278
  where: { access_rules: { id: accessRuleId } },
@@ -75247,6 +75300,74 @@ async function ensureVisitorAccessAuthorization(client, readerId, readerLocation
75247
75300
  }]
75248
75301
  });
75249
75302
  }
75303
+ const existingTimeZoneResponse = await client.loadObjects({
75304
+ object: "time_zones",
75305
+ where: { time_zones: { id: timeZoneId } },
75306
+ limit: 1,
75307
+ offset: 0
75308
+ });
75309
+ if (getHidObjectRows(existingTimeZoneResponse, "time_zones").length) {
75310
+ await client.modifyObjects({
75311
+ object: "time_zones",
75312
+ where: { time_zones: { id: timeZoneId } },
75313
+ values: { name: "iService365 Visitor Schedule" }
75314
+ });
75315
+ } else {
75316
+ await client.createObjects({
75317
+ object: "time_zones",
75318
+ values: [{ id: timeZoneId, name: "iService365 Visitor Schedule" }]
75319
+ });
75320
+ }
75321
+ const visitorTimeSpan = {
75322
+ time_zone_id: timeZoneId,
75323
+ start: 0,
75324
+ end: 86399,
75325
+ sun: 1,
75326
+ mon: 1,
75327
+ tue: 1,
75328
+ wed: 1,
75329
+ thu: 1,
75330
+ fri: 1,
75331
+ sat: 1,
75332
+ hol1: 1,
75333
+ hol2: 1,
75334
+ hol3: 1
75335
+ };
75336
+ const existingTimeSpanResponse = await client.loadObjects({
75337
+ object: "time_spans",
75338
+ where: { time_spans: { id: timeSpanId } },
75339
+ limit: 1,
75340
+ offset: 0
75341
+ });
75342
+ if (getHidObjectRows(existingTimeSpanResponse, "time_spans").length) {
75343
+ await client.modifyObjects({
75344
+ object: "time_spans",
75345
+ where: { time_spans: { id: timeSpanId } },
75346
+ values: visitorTimeSpan
75347
+ });
75348
+ } else {
75349
+ await client.createObjects({
75350
+ object: "time_spans",
75351
+ values: [{ id: timeSpanId, ...visitorTimeSpan }]
75352
+ });
75353
+ }
75354
+ const accessRuleTimeZoneResponse = await client.loadObjects({
75355
+ object: "access_rule_time_zones",
75356
+ where: {
75357
+ access_rule_time_zones: {
75358
+ access_rule_id: accessRuleId,
75359
+ time_zone_id: timeZoneId
75360
+ }
75361
+ },
75362
+ limit: 1,
75363
+ offset: 0
75364
+ });
75365
+ if (!getHidObjectRows(accessRuleTimeZoneResponse, "access_rule_time_zones").length) {
75366
+ await client.createObjects({
75367
+ object: "access_rule_time_zones",
75368
+ values: [{ access_rule_id: accessRuleId, time_zone_id: timeZoneId }]
75369
+ });
75370
+ }
75250
75371
  const portalsResponse = await client.loadObjects({
75251
75372
  object: "portals",
75252
75373
  limit: 100,
@@ -75301,7 +75422,7 @@ async function ensureVisitorAccessAuthorization(client, readerId, readerLocation
75301
75422
  values: missingPortalLinks
75302
75423
  });
75303
75424
  }
75304
- return { accessRuleId, portalIds };
75425
+ return { accessRuleId, portalIds, timeZoneId };
75305
75426
  }
75306
75427
  function getRelayAuth() {
75307
75428
  const username = process.env.HID_AMICO_RELAY_USERNAME;
@@ -75639,19 +75760,95 @@ function normalizeBatches(payload) {
75639
75760
  function firstValue(payload, keys) {
75640
75761
  for (const key of keys) {
75641
75762
  const value = payload?.[key];
75642
- if (value !== void 0 && value !== null && value !== "") {
75763
+ if ((typeof value === "string" || typeof value === "number") && value !== "") {
75643
75764
  return value;
75644
75765
  }
75645
75766
  }
75646
75767
  return void 0;
75647
75768
  }
75648
75769
  function extractIdentityKeys(payload) {
75649
- const nested = payload?.user || payload?.person || payload?.data || {};
75770
+ const nestedCandidate = payload.user || payload.person || payload.data;
75771
+ const nested = isUnknownRecord(nestedCandidate) ? nestedCandidate : {};
75650
75772
  const merged = { ...payload, ...nested };
75773
+ const hidUserId = firstValue(merged, ["hidUserId", "hid_user_id", "user_id", "userId", "id", "user"]);
75774
+ const registration = firstValue(merged, ["registration", "register", "employeeNo", "staffNo", "memberNo"]);
75775
+ const cardNo = firstValue(merged, [
75776
+ "qrcode_value",
75777
+ "qrCodeValue",
75778
+ "qr_code_value",
75779
+ "card_value",
75780
+ "cardNo",
75781
+ "card_no",
75782
+ "card",
75783
+ "cardNumber",
75784
+ "value"
75785
+ ]);
75651
75786
  return {
75652
- hidUserId: firstValue(merged, ["hidUserId", "hid_user_id", "user_id", "userId", "id", "user"]),
75653
- registration: firstValue(merged, ["registration", "register", "employeeNo", "staffNo", "memberNo"]),
75654
- cardNo: firstValue(merged, ["cardNo", "card_no", "card", "cardNumber", "value"])
75787
+ hidUserId,
75788
+ registration: registration === void 0 ? void 0 : String(registration),
75789
+ cardNo: cardNo === void 0 ? void 0 : String(cardNo)
75790
+ };
75791
+ }
75792
+ function toPositiveInteger(value) {
75793
+ const parsed = Number(value);
75794
+ return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : null;
75795
+ }
75796
+ function toDateTimestamp(value) {
75797
+ if (value instanceof Date)
75798
+ return value.getTime();
75799
+ if (typeof value !== "string" && typeof value !== "number")
75800
+ return null;
75801
+ const parsed = new Date(value).getTime();
75802
+ return Number.isFinite(parsed) ? parsed : null;
75803
+ }
75804
+ function buildOnlineIdentificationResponse(identity, payload) {
75805
+ const metadata = identity && isUnknownRecord(identity.metadata) ? identity.metadata : {};
75806
+ const requestedPortalId = toPositiveInteger(payload.portal_id ?? payload.portalId);
75807
+ const configuredPortalIds = Array.isArray(metadata.portalIds) ? metadata.portalIds.map(toPositiveInteger).filter((value) => value !== null) : [];
75808
+ const portalId = requestedPortalId ?? (configuredPortalIds.length === 1 ? configuredPortalIds[0] : null);
75809
+ const now = Date.now();
75810
+ const issuedAt = toDateTimestamp(metadata.issuedAt);
75811
+ const expiresAt = toDateTimestamp(metadata.expiresAt);
75812
+ let deniedReason = "User is not registered for this reader.";
75813
+ if (identity) {
75814
+ deniedReason = "Access denied.";
75815
+ if (identity.type === "visitor" && issuedAt !== null && now < issuedAt) {
75816
+ deniedReason = "Visitor pass is not active yet.";
75817
+ } else if (identity.type === "visitor" && expiresAt !== null && now > expiresAt) {
75818
+ deniedReason = "Visitor pass has expired.";
75819
+ } else if (identity.type === "visitor" && configuredPortalIds.length > 0 && requestedPortalId !== null && !configuredPortalIds.includes(requestedPortalId)) {
75820
+ deniedReason = "Visitor is not permitted to use this portal.";
75821
+ } else {
75822
+ const userId = toPositiveInteger(identity.hidUserId ?? payload.user_id);
75823
+ const userName = String(metadata.name ?? payload.user_name ?? "").trim();
75824
+ const result = {
75825
+ event: 7,
75826
+ user_id: userId ?? 0,
75827
+ user_name: userName,
75828
+ user_image: Boolean(metadata.facialEnrolled),
75829
+ portal_id: portalId === null ? "" : String(portalId),
75830
+ message: "Access granted"
75831
+ };
75832
+ if (portalId !== null) {
75833
+ result.actions = [{ action: "door", parameters: `door=${portalId}` }];
75834
+ }
75835
+ return { granted: true, reason: "Access granted", response: { result } };
75836
+ }
75837
+ }
75838
+ return {
75839
+ granted: false,
75840
+ reason: deniedReason,
75841
+ response: {
75842
+ result: {
75843
+ event: 6,
75844
+ user_id: toPositiveInteger(identity?.hidUserId ?? payload.user_id) ?? 0,
75845
+ user_name: String(metadata.name ?? payload.user_name ?? "").trim(),
75846
+ user_image: Boolean(metadata.facialEnrolled),
75847
+ portal_id: portalId === null ? "" : String(portalId),
75848
+ actions: [],
75849
+ message: deniedReason
75850
+ }
75851
+ }
75655
75852
  };
75656
75853
  }
75657
75854
  function useHidAmicoService() {
@@ -75805,6 +76002,7 @@ function useHidAmicoService() {
75805
76002
  reader: readerId,
75806
76003
  ...identityKeys
75807
76004
  });
76005
+ const authorization = ["new_user_identified", "new_qrcode", "new_card"].includes(type) ? buildOnlineIdentificationResponse(identity, payload) : null;
75808
76006
  const enrichedPayload = identity ? {
75809
76007
  ...payload,
75810
76008
  identity: {
@@ -75817,20 +76015,33 @@ function useHidAmicoService() {
75817
76015
  user: identity.user,
75818
76016
  member: identity.member,
75819
76017
  visitor: identity.visitor
75820
- }
76018
+ },
76019
+ ...authorization ? {
76020
+ authorization: {
76021
+ granted: authorization.granted,
76022
+ reason: authorization.reason
76023
+ }
76024
+ } : {}
75821
76025
  } : {
75822
76026
  ...payload,
75823
76027
  identity: null,
75824
- identityLookup: identityKeys
76028
+ identityLookup: identityKeys,
76029
+ ...authorization ? {
76030
+ authorization: {
76031
+ granted: authorization.granted,
76032
+ reason: authorization.reason
76033
+ }
76034
+ } : {}
75825
76035
  };
75826
76036
  await repo.updateById(readerId, { lastSeenAt: /* @__PURE__ */ new Date() });
75827
- return repo.addEvent({
76037
+ const event = await repo.addEvent({
75828
76038
  reader: readerId,
75829
76039
  site: reader.site,
75830
76040
  type,
75831
76041
  payload: enrichedPayload,
75832
- status: identity ? "processed" : "received"
76042
+ status: authorization ? authorization.granted ? "processed" : "failed" : identity ? "processed" : "received"
75833
76043
  });
76044
+ return { event, response: authorization?.response ?? null };
75834
76045
  }
75835
76046
  async function listLogs(value) {
75836
76047
  return repo.listEvents(value);
@@ -75972,6 +76183,7 @@ function useHidAmicoService() {
75972
76183
  const identityPayload = {
75973
76184
  hidUserId: String(hidUserId),
75974
76185
  registration,
76186
+ cardNo: credential.qrValue,
75975
76187
  visitor: value.visitorId,
75976
76188
  type: "visitor",
75977
76189
  status: "active",
@@ -75981,8 +76193,10 @@ function useHidAmicoService() {
75981
76193
  qrFormat: value.qrFormat,
75982
76194
  credentialObject: credential.object,
75983
76195
  credentialId: credential.credentialId,
76196
+ qrValue: credential.qrValue,
75984
76197
  accessRuleId: authorization.accessRuleId,
75985
76198
  portalIds: authorization.portalIds,
76199
+ timeZoneId: authorization.timeZoneId,
75986
76200
  issuedAt: issuedAt.toISOString(),
75987
76201
  expiresAt: expiresAt.toISOString()
75988
76202
  }
@@ -76008,6 +76222,7 @@ function useHidAmicoService() {
76008
76222
  credentialObject: credential.object,
76009
76223
  accessRuleId: authorization.accessRuleId,
76010
76224
  portalIds: authorization.portalIds,
76225
+ timeZoneId: authorization.timeZoneId,
76011
76226
  issuedAt: issuedAt.toISOString(),
76012
76227
  expiresAt: expiresAt.toISOString()
76013
76228
  }
@@ -76779,18 +76994,28 @@ function useHidAmicoController() {
76779
76994
  }
76780
76995
  }
76781
76996
  async function receiveNotification(req, res, next) {
76782
- const validation = schemaHidAmicoNotificationParams.validate(req.params);
76997
+ const type = String(req.params.type ?? "").replace(/\.fcgi$/i, "");
76998
+ const validation = schemaHidAmicoNotificationParams.validate({
76999
+ ...req.params,
77000
+ type
77001
+ });
76783
77002
  if (validation.error) {
76784
77003
  next(new import_node_server_utils267.BadRequestError(validation.error.message));
76785
77004
  return;
76786
77005
  }
76787
77006
  try {
76788
- const event = await service.receiveNotification(
77007
+ const queryPayload = req.query && typeof req.query === "object" ? req.query : {};
77008
+ const bodyPayload = req.body && typeof req.body === "object" ? req.body : {};
77009
+ const notification = await service.receiveNotification(
76789
77010
  validation.value.readerId,
76790
77011
  validation.value.type,
76791
- req.body ?? {}
77012
+ { ...queryPayload, ...bodyPayload }
76792
77013
  );
76793
- res.status(202).json({ data: event });
77014
+ if (notification.response) {
77015
+ res.status(200).json(notification.response);
77016
+ return;
77017
+ }
77018
+ res.status(202).json({ data: notification.event });
76794
77019
  } catch (error) {
76795
77020
  next(error);
76796
77021
  }