@xenterprises/fastify-xgeocode 1.0.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.
@@ -0,0 +1,363 @@
1
+ import fp from "fastify-plugin";
2
+
3
+ async function xGeocode(fastify, options) {
4
+ const { active = true, apiKey } = options;
5
+
6
+ if (active === false) {
7
+ return;
8
+ }
9
+
10
+ if (!apiKey) {
11
+ throw new Error("xGeocode: apiKey is required in options");
12
+ }
13
+
14
+ const geocodioEndpoint = 'https://api.geocod.io/v1.7/geocode';
15
+ const reverseEndpoint = 'https://api.geocod.io/v1.7/reverse';
16
+
17
+ // Helper to validate zip code format
18
+ function validateZipCode(zipCode) {
19
+ if (!zipCode || typeof zipCode !== 'string') {
20
+ throw new Error('xGeocode: Invalid input - zipCode must be a non-empty string');
21
+ }
22
+
23
+ // Match 5-digit or 9-digit (ZIP+4) format
24
+ if (!/^\d{5}(-\d{4})?$/.test(zipCode.trim())) {
25
+ throw new Error('xGeocode: Invalid zip code format - must be 5 digits or 9 digits (ZIP+4)');
26
+ }
27
+
28
+ return zipCode.trim();
29
+ }
30
+
31
+ // Helper to validate address string
32
+ function validateAddress(address) {
33
+ if (!address || typeof address !== 'string') {
34
+ throw new Error('xGeocode: Invalid input - address must be a non-empty string');
35
+ }
36
+
37
+ if (address.trim().length < 3) {
38
+ throw new Error('xGeocode: Invalid address - minimum 3 characters required');
39
+ }
40
+
41
+ return address.trim();
42
+ }
43
+
44
+ // Helper to validate coordinates
45
+ function validateCoordinates(lat, lng) {
46
+ const latitude = parseFloat(lat);
47
+ const longitude = parseFloat(lng);
48
+
49
+ if (isNaN(latitude) || isNaN(longitude)) {
50
+ throw new Error('xGeocode: Invalid coordinates - latitude and longitude must be numbers');
51
+ }
52
+
53
+ if (latitude < -90 || latitude > 90) {
54
+ throw new Error('xGeocode: Invalid latitude - must be between -90 and 90');
55
+ }
56
+
57
+ if (longitude < -180 || longitude > 180) {
58
+ throw new Error('xGeocode: Invalid longitude - must be between -180 and 180');
59
+ }
60
+
61
+ return { latitude, longitude };
62
+ }
63
+
64
+ // Helper to parse API response
65
+ function parseGeocodeResult(result) {
66
+ if (!result.results || result.results.length === 0) {
67
+ throw new Error('xGeocode: No results found');
68
+ }
69
+
70
+ const location = result.results[0].location;
71
+ const addressComponents = result.results[0].address_components;
72
+
73
+ return {
74
+ lat: location.lat,
75
+ lng: location.lng,
76
+ formatted_address: result.results[0].formatted_address || null,
77
+ city: addressComponents.city || null,
78
+ county: addressComponents.county || null,
79
+ country: addressComponents.country || null,
80
+ state: addressComponents.state || null,
81
+ zip: addressComponents.zip || null,
82
+ addressComponents
83
+ };
84
+ }
85
+
86
+ // Haversine formula to calculate distance between two points
87
+ function calculateHaversineDistance(lat1, lng1, lat2, lng2) {
88
+ const R = 6371; // Earth's radius in kilometers
89
+ const dLat = (lat2 - lat1) * (Math.PI / 180);
90
+ const dLng = (lng2 - lng1) * (Math.PI / 180);
91
+
92
+ const a = Math.sin(dLat / 2) * Math.sin(dLat / 2) +
93
+ Math.cos(lat1 * (Math.PI / 180)) * Math.cos(lat2 * (Math.PI / 180)) *
94
+ Math.sin(dLng / 2) * Math.sin(dLng / 2);
95
+
96
+ const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
97
+ const km = R * c;
98
+
99
+ return {
100
+ kilometers: Math.round(km * 100) / 100,
101
+ miles: Math.round((km * 0.621371) * 100) / 100,
102
+ meters: Math.round(km * 1000)
103
+ };
104
+ }
105
+
106
+ fastify.decorate('xGeocode', {
107
+ /**
108
+ * Get latitude, longitude, and address components from a zip code
109
+ * @param {string} zipCode - The zip code to geocode (5 or 9 digits)
110
+ * @returns {Promise<Object>} Object with zip, lat, lng, city, county, country, state, and addressComponents
111
+ */
112
+ async getLatLongByZip(zipCode) {
113
+ try {
114
+ const validZip = validateZipCode(zipCode);
115
+ const url = `${geocodioEndpoint}?q=${encodeURIComponent(validZip)}&fields=cd,stateleg&api_key=${apiKey}`;
116
+ const response = await fetch(url);
117
+
118
+ if (!response.ok) {
119
+ const error = process.env.NODE_ENV === 'production'
120
+ ? 'Geocoding service unavailable'
121
+ : `Geocoding API returned ${response.status}`;
122
+ throw new Error(error);
123
+ }
124
+
125
+ const result = await response.json();
126
+ const parsed = parseGeocodeResult(result);
127
+
128
+ return {
129
+ zip: validZip,
130
+ ...parsed
131
+ };
132
+ } catch (error) {
133
+ const message = error.message || 'Unknown error in geocoding';
134
+ const sanitizedMessage = process.env.NODE_ENV === 'production'
135
+ ? 'Failed to geocode address'
136
+ : message;
137
+
138
+ if (process.env.NODE_ENV !== 'production') {
139
+ console.error('xGeocode error:', message);
140
+ }
141
+
142
+ throw new Error(sanitizedMessage);
143
+ }
144
+ },
145
+
146
+ /**
147
+ * Get latitude, longitude, and address components from a street address
148
+ * @param {string} address - The street address to geocode (e.g., "123 Main St, New York, NY")
149
+ * @returns {Promise<Object>} Object with lat, lng, formatted_address, city, state, etc.
150
+ */
151
+ async getLatLongByAddress(address) {
152
+ try {
153
+ const validAddress = validateAddress(address);
154
+ const url = `${geocodioEndpoint}?q=${encodeURIComponent(validAddress)}&fields=cd,stateleg&api_key=${apiKey}`;
155
+ const response = await fetch(url);
156
+
157
+ if (!response.ok) {
158
+ const error = process.env.NODE_ENV === 'production'
159
+ ? 'Geocoding service unavailable'
160
+ : `Geocoding API returned ${response.status}`;
161
+ throw new Error(error);
162
+ }
163
+
164
+ const result = await response.json();
165
+ return parseGeocodeResult(result);
166
+ } catch (error) {
167
+ const message = error.message || 'Unknown error in geocoding';
168
+ const sanitizedMessage = process.env.NODE_ENV === 'production'
169
+ ? 'Failed to geocode address'
170
+ : message;
171
+
172
+ if (process.env.NODE_ENV !== 'production') {
173
+ console.error('xGeocode error:', message);
174
+ }
175
+
176
+ throw new Error(sanitizedMessage);
177
+ }
178
+ },
179
+
180
+ /**
181
+ * Get address from latitude and longitude (reverse geocoding)
182
+ * @param {number} lat - Latitude coordinate
183
+ * @param {number} lng - Longitude coordinate
184
+ * @returns {Promise<Object>} Object with formatted_address, city, state, zip, etc.
185
+ */
186
+ async getReverseGeocode(lat, lng) {
187
+ try {
188
+ const { latitude, longitude } = validateCoordinates(lat, lng);
189
+ const url = `${reverseEndpoint}?q=${latitude},${longitude}&fields=cd,stateleg&api_key=${apiKey}`;
190
+ const response = await fetch(url);
191
+
192
+ if (!response.ok) {
193
+ const error = process.env.NODE_ENV === 'production'
194
+ ? 'Reverse geocoding service unavailable'
195
+ : `Reverse geocoding API returned ${response.status}`;
196
+ throw new Error(error);
197
+ }
198
+
199
+ const result = await response.json();
200
+ return parseGeocodeResult(result);
201
+ } catch (error) {
202
+ const message = error.message || 'Unknown error in reverse geocoding';
203
+ const sanitizedMessage = process.env.NODE_ENV === 'production'
204
+ ? 'Failed to reverse geocode coordinates'
205
+ : message;
206
+
207
+ if (process.env.NODE_ENV !== 'production') {
208
+ console.error('xGeocode error:', message);
209
+ }
210
+
211
+ throw new Error(sanitizedMessage);
212
+ }
213
+ },
214
+
215
+ /**
216
+ * Calculate distance between two geographic points
217
+ * @param {number} lat1 - Origin latitude
218
+ * @param {number} lng1 - Origin longitude
219
+ * @param {number} lat2 - Destination latitude
220
+ * @param {number} lng2 - Destination longitude
221
+ * @returns {Object} Distance in kilometers, miles, and meters
222
+ */
223
+ getDistance(lat1, lng1, lat2, lng2) {
224
+ try {
225
+ const origin = validateCoordinates(lat1, lng1);
226
+ const destination = validateCoordinates(lat2, lng2);
227
+
228
+ return calculateHaversineDistance(
229
+ origin.latitude,
230
+ origin.longitude,
231
+ destination.latitude,
232
+ destination.longitude
233
+ );
234
+ } catch (error) {
235
+ const message = error.message || 'Unknown error calculating distance';
236
+ const sanitizedMessage = process.env.NODE_ENV === 'production'
237
+ ? 'Failed to calculate distance'
238
+ : message;
239
+
240
+ if (process.env.NODE_ENV !== 'production') {
241
+ console.error('xGeocode error:', message);
242
+ }
243
+
244
+ throw new Error(sanitizedMessage);
245
+ }
246
+ },
247
+
248
+ /**
249
+ * Geocode multiple addresses or ZIP codes in a single operation
250
+ * @param {Array<string>} locations - Array of addresses or ZIP codes
251
+ * @returns {Promise<Array>} Array of geocoded results
252
+ */
253
+ async batchGeocode(locations) {
254
+ try {
255
+ if (!Array.isArray(locations)) {
256
+ throw new Error('xGeocode: locations must be an array');
257
+ }
258
+
259
+ if (locations.length === 0) {
260
+ throw new Error('xGeocode: locations array cannot be empty');
261
+ }
262
+
263
+ if (locations.length > 100) {
264
+ throw new Error('xGeocode: batch size cannot exceed 100 locations');
265
+ }
266
+
267
+ const results = await Promise.all(
268
+ locations.map(async (location) => {
269
+ try {
270
+ // Try as ZIP code first, fall back to address
271
+ if (/^\d{5}(-\d{4})?$/.test(location.trim())) {
272
+ return await this.getLatLongByZip(location);
273
+ } else {
274
+ const result = await this.getLatLongByAddress(location);
275
+ return { original: location, ...result };
276
+ }
277
+ } catch (error) {
278
+ return {
279
+ original: location,
280
+ error: error.message,
281
+ success: false
282
+ };
283
+ }
284
+ })
285
+ );
286
+
287
+ return results;
288
+ } catch (error) {
289
+ const message = error.message || 'Unknown error in batch geocoding';
290
+ const sanitizedMessage = process.env.NODE_ENV === 'production'
291
+ ? 'Failed to batch geocode addresses'
292
+ : message;
293
+
294
+ if (process.env.NODE_ENV !== 'production') {
295
+ console.error('xGeocode error:', message);
296
+ }
297
+
298
+ throw new Error(sanitizedMessage);
299
+ }
300
+ },
301
+
302
+ /**
303
+ * Validate and get standardized format for an address
304
+ * @param {string} address - The address to validate
305
+ * @returns {Promise<Object>} Object with validity, formatted address, and components
306
+ */
307
+ async validateAddress(address) {
308
+ try {
309
+ const validAddress = validateAddress(address);
310
+
311
+ // Use geocoding to validate the address
312
+ const url = `${geocodioEndpoint}?q=${encodeURIComponent(validAddress)}&fields=cd,stateleg&api_key=${apiKey}`;
313
+ const response = await fetch(url);
314
+
315
+ if (!response.ok) {
316
+ return {
317
+ valid: false,
318
+ input: address,
319
+ error: process.env.NODE_ENV === 'production'
320
+ ? 'Validation service unavailable'
321
+ : `API returned ${response.status}`
322
+ };
323
+ }
324
+
325
+ const result = await response.json();
326
+
327
+ if (!result.results || result.results.length === 0) {
328
+ return {
329
+ valid: false,
330
+ input: address,
331
+ error: 'Address not recognized'
332
+ };
333
+ }
334
+
335
+ const parsed = parseGeocodeResult(result);
336
+ return {
337
+ valid: true,
338
+ input: address,
339
+ formatted: result.results[0].formatted_address,
340
+ confidence: result.results[0].accuracy || 'unknown',
341
+ ...parsed
342
+ };
343
+ } catch (error) {
344
+ const message = error.message || 'Unknown error validating address';
345
+ const sanitizedMessage = process.env.NODE_ENV === 'production'
346
+ ? 'Failed to validate address'
347
+ : message;
348
+
349
+ if (process.env.NODE_ENV !== 'production') {
350
+ console.error('xGeocode error:', message);
351
+ }
352
+
353
+ throw new Error(sanitizedMessage);
354
+ }
355
+ }
356
+ });
357
+
358
+ console.info(" ✅ Geocoding Enabled");
359
+ }
360
+
361
+ export default fp(xGeocode, {
362
+ name: "xGeocode",
363
+ });