@remit/drizzle-service 0.0.44 → 0.0.45
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/package.json
CHANGED
|
@@ -8,10 +8,10 @@ import { addressTable } from "../schema/i4-address.js";
|
|
|
8
8
|
const escapeLike = (term: string): string => term.replace(/[\\%_]/g, "\\$&");
|
|
9
9
|
|
|
10
10
|
const SEARCH_COLUMNS = [
|
|
11
|
-
sql
|
|
11
|
+
sql`${addressTable.normalizedEmail}`,
|
|
12
12
|
sql`${addressTable.localPart}`,
|
|
13
|
+
sql`lower(coalesce(${addressTable.displayName}, ''))`,
|
|
13
14
|
sql`${addressTable.domain}`,
|
|
14
|
-
sql`${addressTable.normalizedEmail}`,
|
|
15
15
|
] as const;
|
|
16
16
|
|
|
17
17
|
/**
|
|
@@ -45,22 +45,27 @@ export const addressSearchMatch = (term: string): SQL => {
|
|
|
45
45
|
};
|
|
46
46
|
|
|
47
47
|
/**
|
|
48
|
-
* Where the term hit, as one number
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
48
|
+
* Where the term hit, as one number, highest tier first. The address the term
|
|
49
|
+
* spells out ranks above every prefix of it: a display name is free text the
|
|
50
|
+
* sender picks, and a domain somebody else registered is a prefix away from the
|
|
51
|
+
* address the reader typed, so neither may take the suggestion slot from the
|
|
52
|
+
* address that matches whole. Below that, a match at the start of a column
|
|
53
|
+
* outranks one in the middle, and within each the whole address, the local part
|
|
54
|
+
* and the display name outrank the domain they share. A mid-string match still
|
|
55
|
+
* comes back — this only decides the order.
|
|
52
56
|
*/
|
|
53
57
|
export const addressMatchRank = (term: string | undefined): SQL<number> => {
|
|
54
58
|
// Not the bare literal `0`: SQLite reads an integer literal in ORDER BY as a
|
|
55
59
|
// column index and rejects it as out of range.
|
|
56
60
|
if (!term) return sql<number>`cast(0 as integer)`;
|
|
57
61
|
const { leading, anywhere } = patterns(term);
|
|
58
|
-
const
|
|
62
|
+
const tiers = [
|
|
63
|
+
sql`${addressTable.normalizedEmail} = ${term.toLowerCase()}`,
|
|
59
64
|
...SEARCH_COLUMNS.map((column) => like(column, leading)),
|
|
60
65
|
...SEARCH_COLUMNS.map((column) => like(column, anywhere)),
|
|
61
|
-
]
|
|
62
|
-
|
|
63
|
-
|
|
66
|
+
];
|
|
67
|
+
const arms = tiers.map(
|
|
68
|
+
(condition, index) => sql`when ${condition} then ${tiers.length - index}`,
|
|
64
69
|
);
|
|
65
70
|
return sql<number>`case ${sql.join(arms, sql` `)} else 0 end`;
|
|
66
71
|
};
|
|
@@ -417,6 +417,156 @@ describe("AddressRepo", () => {
|
|
|
417
417
|
]);
|
|
418
418
|
});
|
|
419
419
|
|
|
420
|
+
test("listByAccountConfig never lets a display name outrank the address a term matches", async () => {
|
|
421
|
+
const accountConfigId = randomId();
|
|
422
|
+
// The 2021 spam row: its display name is the account's own address, its
|
|
423
|
+
// real address is somewhere else entirely.
|
|
424
|
+
const spoof = await repo.createAddress({
|
|
425
|
+
...makeAddressInput(
|
|
426
|
+
accountConfigId,
|
|
427
|
+
"aramirez@secresaludguaviare.gov.co",
|
|
428
|
+
),
|
|
429
|
+
displayName: "matthijs@ischen.nl",
|
|
430
|
+
normalizedCompound:
|
|
431
|
+
"matthijs@ischen.nl aramirez@secresaludguaviare.gov.co",
|
|
432
|
+
inboundCount: 500,
|
|
433
|
+
});
|
|
434
|
+
// The address typed, as a prefix of a domain somebody else registered,
|
|
435
|
+
// louder than the account's own correspondence with itself.
|
|
436
|
+
const lookalike = await repo.createAddress({
|
|
437
|
+
...makeAddressInput(accountConfigId, "matthijs@ischen.nl.evil.example"),
|
|
438
|
+
displayName: "matthijs@ischen.nl",
|
|
439
|
+
normalizedCompound: "matthijs@ischen.nl matthijs@ischen.nl.evil.example",
|
|
440
|
+
inboundCount: 900,
|
|
441
|
+
});
|
|
442
|
+
const own = await repo.createAddress({
|
|
443
|
+
...makeAddressInput(accountConfigId, "matthijs@ischen.nl"),
|
|
444
|
+
displayName: "Matthijs van Henten",
|
|
445
|
+
normalizedCompound: "matthijs van henten matthijs@ischen.nl",
|
|
446
|
+
inboundCount: 3,
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
const found = await repo.listByAccountConfig({
|
|
450
|
+
accountConfigId,
|
|
451
|
+
search: "matthijs@ischen.nl",
|
|
452
|
+
});
|
|
453
|
+
assert.deepEqual(
|
|
454
|
+
found.items.map((a) => a.normalizedEmail),
|
|
455
|
+
[own.normalizedEmail, lookalike.normalizedEmail, spoof.normalizedEmail],
|
|
456
|
+
"the address typed must lead every address that only prefixes or claims it",
|
|
457
|
+
);
|
|
458
|
+
|
|
459
|
+
const onlySuggestion = await repo.listByAccountConfig({
|
|
460
|
+
accountConfigId,
|
|
461
|
+
search: "matthijs@ischen.nl",
|
|
462
|
+
limit: 1,
|
|
463
|
+
});
|
|
464
|
+
assert.deepEqual(
|
|
465
|
+
onlySuggestion.items.map((a) => a.normalizedEmail),
|
|
466
|
+
[own.normalizedEmail],
|
|
467
|
+
"a single suggestion slot goes to the address that matches whole",
|
|
468
|
+
);
|
|
469
|
+
|
|
470
|
+
await repo.deleteManyAddresses(accountConfigId, [
|
|
471
|
+
spoof.addressId,
|
|
472
|
+
lookalike.addressId,
|
|
473
|
+
own.addressId,
|
|
474
|
+
]);
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
test("listByAccountConfig keeps a leading display-name match above a domain that only contains the term", async () => {
|
|
478
|
+
const accountConfigId = randomId();
|
|
479
|
+
const shop = await repo.createAddress({
|
|
480
|
+
...makeAddressInput(accountConfigId, "hello@other.test"),
|
|
481
|
+
displayName: "Corner Shop",
|
|
482
|
+
normalizedCompound: "corner shop hello@other.test",
|
|
483
|
+
});
|
|
484
|
+
const leadingDomain = await repo.createAddress({
|
|
485
|
+
...makeAddressInput(
|
|
486
|
+
accountConfigId,
|
|
487
|
+
"sales@cornerstone-analytics.example",
|
|
488
|
+
),
|
|
489
|
+
displayName: "Sales Team",
|
|
490
|
+
normalizedCompound: "sales team sales@cornerstone-analytics.example",
|
|
491
|
+
inboundCount: 200,
|
|
492
|
+
});
|
|
493
|
+
const middleDomain = await repo.createAddress({
|
|
494
|
+
...makeAddressInput(accountConfigId, "news@list-corner.example"),
|
|
495
|
+
displayName: "Loud List",
|
|
496
|
+
normalizedCompound: "loud list news@list-corner.example",
|
|
497
|
+
inboundCount: 500,
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
const found = await repo.listByAccountConfig({
|
|
501
|
+
accountConfigId,
|
|
502
|
+
search: "corner",
|
|
503
|
+
});
|
|
504
|
+
assert.deepEqual(
|
|
505
|
+
found.items.map((a) => a.addressId),
|
|
506
|
+
[shop.addressId, leadingDomain.addressId, middleDomain.addressId],
|
|
507
|
+
"the name a reader types stays the first suggestion",
|
|
508
|
+
);
|
|
509
|
+
|
|
510
|
+
await repo.deleteManyAddresses(accountConfigId, [
|
|
511
|
+
shop.addressId,
|
|
512
|
+
leadingDomain.addressId,
|
|
513
|
+
middleDomain.addressId,
|
|
514
|
+
]);
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
test("listByAccountConfig ranks an address match above a display-name match for the same term", async () => {
|
|
518
|
+
const accountConfigId = randomId();
|
|
519
|
+
const byAddress = await repo.createAddress({
|
|
520
|
+
...makeAddressInput(accountConfigId, "corner@shop.test"),
|
|
521
|
+
displayName: "Zed Ziegler",
|
|
522
|
+
normalizedCompound: "zed ziegler corner@shop.test",
|
|
523
|
+
});
|
|
524
|
+
const byName = await repo.createAddress({
|
|
525
|
+
...makeAddressInput(accountConfigId, "hello@other.test"),
|
|
526
|
+
displayName: "Corner Shop",
|
|
527
|
+
normalizedCompound: "corner shop hello@other.test",
|
|
528
|
+
inboundCount: 500,
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
const found = await repo.listByAccountConfig({
|
|
532
|
+
accountConfigId,
|
|
533
|
+
search: "corner",
|
|
534
|
+
});
|
|
535
|
+
assert.deepEqual(
|
|
536
|
+
found.items.map((a) => a.addressId),
|
|
537
|
+
[byAddress.addressId, byName.addressId],
|
|
538
|
+
"the address decides ahead of free text the sender wrote",
|
|
539
|
+
);
|
|
540
|
+
|
|
541
|
+
await repo.deleteManyAddresses(accountConfigId, [
|
|
542
|
+
byAddress.addressId,
|
|
543
|
+
byName.addressId,
|
|
544
|
+
]);
|
|
545
|
+
});
|
|
546
|
+
|
|
547
|
+
test("listByAccountConfig still resolves a display-name match no address matches", async () => {
|
|
548
|
+
const accountConfigId = randomId();
|
|
549
|
+
const created = await repo.createAddress({
|
|
550
|
+
...makeAddressInput(accountConfigId, "w.baker@kliniek.nl"),
|
|
551
|
+
displayName: "Wendy Baker",
|
|
552
|
+
normalizedCompound: "wendy baker w.baker@kliniek.nl",
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
for (const term of ["wendy", "baker", "wendy baker"]) {
|
|
556
|
+
const found = await repo.listByAccountConfig({
|
|
557
|
+
accountConfigId,
|
|
558
|
+
search: term,
|
|
559
|
+
});
|
|
560
|
+
assert.deepEqual(
|
|
561
|
+
found.items.map((a) => a.addressId),
|
|
562
|
+
[created.addressId],
|
|
563
|
+
`"${term}" must resolve the address by its display name`,
|
|
564
|
+
);
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
await repo.deleteManyAddresses(accountConfigId, [created.addressId]);
|
|
568
|
+
});
|
|
569
|
+
|
|
420
570
|
test("cross-tenant: a search never reaches another account's addresses", async () => {
|
|
421
571
|
const mine = randomId();
|
|
422
572
|
const theirs = randomId();
|