• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

GrottoCenter / grottocenter-api / 25580542847

08 May 2026 09:30PM UTC coverage: 86.665% (-0.2%) from 86.873%
25580542847

Pull #1573

github

ClemRz
feat(geocoding): replace sync Nominatim with offline country resolution and async queue

Resolves #1570

- Add CountryResolverService using @rapideditor/country-coder for offline
  point-in-polygon country resolution with hierarchy-based fallback
- Add EnrichmentQueueService using pg-boss (PostgreSQL-backed) for async
  Nominatim enrichment of region, county, city, iso_3166_2
- Remove all synchronous GeocodingService.reverse() calls from entrance
  create/update and organization create/update
- Country cache loaded from t_country at startup, held in memory
- Queue processes at 1 req/s with exponential backoff retry (max 5)
- Stale enrichment fields cleared on coordinate change
- Trace ID propagated from request to background job logs
- Fallback to '00' (Undefined) when country cannot be determined
Pull Request #1573: feat(geocoding): offline country resolution + async enrichment queue

3082 of 3682 branches covered (83.7%)

Branch coverage included in aggregate %.

94 of 126 new or added lines in 8 files covered. (74.6%)

1 existing line in 1 file now uncovered.

6329 of 7177 relevant lines covered (88.18%)

53.14 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

85.33
/api/services/GeocodingService.js
1
const https = require('https');
2✔
2
const isoCodes = require('../../config/constants/iso3166_2_codes.json');
2✔
3

4
const later = (delayMs, value) =>
2✔
5
  new Promise((resolve) => {
6✔
6
    setTimeout(resolve, delayMs, value);
6✔
7
  });
8

9
const NOMINATIM_USER_AGENT = 'Grottocenter nodejs';
2✔
10

11
let ratelimitQueue = [];
2✔
12

13
async function ratelimitNominatim() {
14
  // We limit the request to nominatim to at most one per second
15
  // https://operations.osmfoundation.org/policies/nominatim/
16
  // If the queue get too long the request is canceled
17
  const CANCEL_AFTER_QUEUE_LENGH = 5;
11✔
18

19
  const now = Date.now();
11✔
20
  ratelimitQueue = ratelimitQueue.filter((e) => e > now - 10);
40✔
21

22
  if (ratelimitQueue.length >= CANCEL_AFTER_QUEUE_LENGH) return false;
11✔
23

24
  const nextExecTs =
25
    ratelimitQueue.length === 0
6✔
26
      ? now
27
      : ratelimitQueue[ratelimitQueue.length - 1] + 1000;
28
  ratelimitQueue.push(nextExecTs);
6✔
29
  return later(nextExecTs - now, true);
6✔
30
}
31

32
async function osmNominatimReverse(latitude, longitude) {
33
  return new Promise((resolve, reject) => {
6✔
34
    // https://nominatim.org/release-docs/latest/api/Reverse/
35
    // There is no 'accept-language' header to have the data in their default language
36
    const url = `https://nominatim.openstreetmap.org/reverse?format=jsonv2&addressdetails=1&zoom=10&lat=${latitude}&lon=${longitude}`;
6✔
37
    https
6✔
38
      .get(url, { headers: { 'user-agent': NOMINATIM_USER_AGENT } }, (res) => {
39
        if (!res.headers['content-type'].startsWith('application/json')) {
6!
40
          reject(
×
41
            new Error(
42
              `Error: Content type is not json: ${res.headers['content-type']}`
43
            )
44
          );
45
          return;
×
46
        }
47

48
        const chunks = [];
6✔
49
        res.on('data', (d) => chunks.push(d));
6✔
50
        res.on('end', () => resolve(JSON.parse(Buffer.concat(chunks))));
6✔
51
      })
52
      .on('error', (err) => reject(err));
×
53
  });
54
}
55

56
module.exports = {
2✔
57
  async reverse(latitude, longitude) {
58
    const canContinue = await ratelimitNominatim();
11✔
59
    if (!canContinue) return null;
11✔
60

61
    const rep = await osmNominatimReverse(latitude, longitude).catch((err) =>
6✔
62
      sails.log.error('osmNominatimReverse', latitude, longitude, err)
×
63
    );
64

65
    // https://nominatim.org/release-docs/latest/api/Output/#addressdetails
66
    const addr = rep?.address;
6✔
67
    if (!addr) return null;
6✔
68
    const isoKeys = Object.keys(addr ?? {})
5!
69
      .filter((e) => e.startsWith('ISO3166-2-lvl'))
5✔
UNCOV
70
      .map((e) => parseInt(e.slice(13), 10));
×
71
    isoKeys.sort((a, b) => b - a); // highest level
5✔
72

73
    return {
5✔
74
      region: addr?.state ?? addr?.region ?? null,
15✔
75
      county: addr?.county ?? addr?.state_district ?? null,
15✔
76
      city:
77
        addr?.village ?? addr?.city ?? addr?.town ?? addr?.municipality ?? null,
25✔
78
      id_country: addr?.country_code?.toUpperCase() ?? null,
5!
79
      iso_3166_2:
80
        isoKeys.length > 0 ? addr[`ISO3166-2-lvl${isoKeys[0]}`] : null,
5!
81
    };
82
  },
83

84
  findISOHierarchy(isocode) {
85
    const path = [];
2✔
86
    let codeLevel = isocode;
2✔
87
    do {
2✔
88
      // eslint-disable-next-line no-loop-func
89
      const entry = isoCodes['3166-2'].find((e) => e.code === codeLevel);
6,284✔
90
      path.push(entry);
2✔
91
      if (entry.parent)
2!
92
        codeLevel = `${entry.code.split('-')[0]}-${entry.parent}`;
×
93
      else codeLevel = '';
2✔
94
    } while (codeLevel);
95
    return path.reverse();
2✔
96
  },
97

98
  async getISOTranslation(isocodes) {
99
    return TISO31662.find({ id: isocodes });
2✔
100
  },
101
};
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc