@7365admin1/core 3.41.0 → 3.41.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/CHANGELOG.md CHANGED
@@ -1,5 +1,32 @@
1
1
  # @iservice365/core
2
2
 
3
+ ## 3.41.1
4
+
5
+ ### Patch Changes
6
+
7
+ - 8eb6601: Stop rejecting site names that only differ by their building number
8
+
9
+ Adding a site refused any name within a Levenshtein distance of 2 of an
10
+ existing site in the same organisation, and told the user to contact support.
11
+ That is exactly one character, so "Winsland House II" could not be added next
12
+ to "Winsland House I". The same rule blocked "Tower A" beside "Tower B",
13
+ "Phase 2" beside "Phase 1" and "Block 15" beside "Block 5" — the standard way
14
+ buildings are named here.
15
+
16
+ The check now treats trailing numbers, Roman numerals and single letters as the
17
+ part that tells two buildings apart: if they differ, the names are different
18
+ sites and the distance is never measured. Real duplicates are still refused —
19
+ an exact repeat, a different capitalisation, stray or doubled whitespace,
20
+ punctuation-only differences, and a one or two character typo within the same
21
+ building. "House 1" and "House I" are still read as the same building.
22
+
23
+ The refusal now names the site it matched and says what to do about it instead
24
+ of pointing the user at support.
25
+
26
+ `site.repo.getByExactName` also built its case-insensitive regex from the raw
27
+ name; a name containing regex characters either threw or matched a site it is
28
+ not. It is escaped.
29
+
3
30
  ## 3.41.0
4
31
 
5
32
  ### Minor Changes
package/dist/index.js CHANGED
@@ -11666,8 +11666,9 @@ function useSiteRepo() {
11666
11666
  } catch (error2) {
11667
11667
  throw new import_node_server_utils19.BadRequestError("Invalid org ID format.");
11668
11668
  }
11669
+ const escapedName = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
11669
11670
  const query = {
11670
- name: { $regex: new RegExp(`^${name}$`, "i") },
11671
+ name: { $regex: new RegExp(`^${escapedName}$`, "i") },
11671
11672
  // Case-insensitive exact match
11672
11673
  orgId,
11673
11674
  status: { $ne: "deleted" }
@@ -32537,7 +32538,57 @@ function useCustomerSiteRepo() {
32537
32538
 
32538
32539
  // src/services/customer-site.service.ts
32539
32540
  var import_node_server_utils96 = require("@7365admin1/node-server-utils");
32541
+
32542
+ // src/utils/site-name.util.ts
32540
32543
  var import_fast_levenshtein = __toESM(require("fast-levenshtein"));
32544
+ var ROMAN = /^(?=[ivx])(x{0,3})(ix|iv|v?i{0,3})$/;
32545
+ var ROMAN_VALUE = { i: 1, v: 5, x: 10 };
32546
+ function normalizeSiteName(name) {
32547
+ return String(name ?? "").normalize("NFKC").toLowerCase().replace(/[‘’']/g, "").replace(/[^a-z0-9]+/g, " ").trim();
32548
+ }
32549
+ function romanToNumber(token) {
32550
+ let total = 0;
32551
+ for (let i = 0; i < token.length; i++) {
32552
+ const value = ROMAN_VALUE[token[i]];
32553
+ const next = ROMAN_VALUE[token[i + 1]];
32554
+ total += next && next > value ? -value : value;
32555
+ }
32556
+ return total;
32557
+ }
32558
+ function distinguishingTokens(normalized) {
32559
+ if (!normalized)
32560
+ return [];
32561
+ return normalized.split(" ").filter((t) => /^\d+$/.test(t) || ROMAN.test(t) || /^[a-z]$/.test(t)).map((t) => ROMAN.test(t) ? String(romanToNumber(t)) : t);
32562
+ }
32563
+ function matchSiteName(candidate, existing) {
32564
+ const a = normalizeSiteName(candidate);
32565
+ const b = normalizeSiteName(existing);
32566
+ if (!a || !b)
32567
+ return null;
32568
+ if (a === b)
32569
+ return "duplicate";
32570
+ const tokensA = distinguishingTokens(a).join(" ");
32571
+ const tokensB = distinguishingTokens(b).join(" ");
32572
+ if (tokensA !== tokensB)
32573
+ return null;
32574
+ return import_fast_levenshtein.default.get(a, b) <= 2 ? "near-duplicate" : null;
32575
+ }
32576
+ function findSiteNameClash(candidate, existingNames) {
32577
+ for (const name of existingNames) {
32578
+ const match = matchSiteName(candidate, name);
32579
+ if (match)
32580
+ return { name, match };
32581
+ }
32582
+ return null;
32583
+ }
32584
+ function siteNameClashMessage(existingName, match) {
32585
+ if (match === "duplicate") {
32586
+ return `A site called "${existingName}" already exists in this organisation. Give the new site a different name, or edit "${existingName}" instead.`;
32587
+ }
32588
+ 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.`;
32589
+ }
32590
+
32591
+ // src/services/customer-site.service.ts
32541
32592
  function useCustomerSiteService() {
32542
32593
  const { add: _add, updateCustomerSiteById, getById: _getById } = useCustomerSiteRepo();
32543
32594
  const {
@@ -32559,20 +32610,18 @@ function useCustomerSiteService() {
32559
32610
  const exactMatches = await getSiteByExactName(value.name, value.siteOrg);
32560
32611
  if (exactMatches && exactMatches.length > 0) {
32561
32612
  throw new import_node_server_utils96.BadRequestError(
32562
- "Site with this name already exists in the organization."
32613
+ siteNameClashMessage(exactMatches[0].name, "duplicate")
32563
32614
  );
32564
32615
  }
32565
- const threshold = 2;
32566
32616
  const sites = await getSiteByName(value.name);
32567
- if (sites && sites.length > 0) {
32568
- const similar = sites.filter((doc) => {
32569
- return doc.orgId && doc.name && doc.orgId.toString() === value.siteOrg.toString() && import_fast_levenshtein.default.get(value.name.toLowerCase(), doc.name.toLowerCase()) <= threshold;
32570
- });
32571
- if (similar.length > 0) {
32572
- throw new import_node_server_utils96.BadRequestError(
32573
- "Failed to add site, site with closely similar name found. Please contact seven365 support."
32574
- );
32575
- }
32617
+ const candidates = (sites ?? []).filter(
32618
+ (doc) => doc.orgId && doc.name && doc.orgId.toString() === value.siteOrg.toString()
32619
+ ).map((doc) => doc.name);
32620
+ const clash = findSiteNameClash(value.name, candidates);
32621
+ if (clash) {
32622
+ throw new import_node_server_utils96.BadRequestError(
32623
+ siteNameClashMessage(clash.name, clash.match)
32624
+ );
32576
32625
  }
32577
32626
  const siteId = await createSite(
32578
32627
  {