@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 +27 -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 +1 -1
- package/test/site-name.util.test.mjs +146 -0
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(`^${
|
|
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
|
-
|
|
32613
|
+
siteNameClashMessage(exactMatches[0].name, "duplicate")
|
|
32563
32614
|
);
|
|
32564
32615
|
}
|
|
32565
|
-
const threshold = 2;
|
|
32566
32616
|
const sites = await getSiteByName(value.name);
|
|
32567
|
-
|
|
32568
|
-
|
|
32569
|
-
|
|
32570
|
-
|
|
32571
|
-
|
|
32572
|
-
|
|
32573
|
-
|
|
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
|
{
|