@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.
- package/.changeset/site-name-similarity-check.md +26 -0
- package/dist/index.js +61 -12
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +61 -12
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
- package/test/site-name.util.test.mjs +146 -0
|
@@ -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(`^${
|
|
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
|
-
|
|
35159
|
+
siteNameClashMessage(exactMatches[0].name, "duplicate")
|
|
35109
35160
|
);
|
|
35110
35161
|
}
|
|
35111
|
-
const threshold = 2;
|
|
35112
35162
|
const sites = await getSiteByName(value.name);
|
|
35113
|
-
|
|
35114
|
-
|
|
35115
|
-
|
|
35116
|
-
|
|
35117
|
-
|
|
35118
|
-
|
|
35119
|
-
|
|
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
|
{
|