@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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/drizzle-service",
3
- "version": "0.0.44",
3
+ "version": "0.0.45",
4
4
  "description": "Drizzle ORM service over SQLite",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -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`lower(coalesce(${addressTable.displayName}, ''))`,
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: every match at the start of a column
49
- * outranks every match in the middle of one, and within each the display name
50
- * outranks the local part, the domain and the whole address. A mid-string match
51
- * still comes back this only decides the order.
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 arms = [
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
- ].map(
62
- (condition, index) =>
63
- sql`when ${condition} then ${SEARCH_COLUMNS.length * 2 - index}`,
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();