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

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.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
  {