@sanvika/geolocation 0.2.1 → 0.3.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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@sanvika/geolocation",
3
- "version": "0.2.1",
4
- "description": "Sanvika ecosystem geolocation SDK — IP lookup, browser geolocation, reverse geocoding (lat/lng Indian state/city), Indian states constants",
3
+ "version": "0.3.1",
4
+ "description": "Sanvika ecosystem geolocation SDK — fully centralized via geolocation.sanvikaproduction.com. IP lookup, reverse/forward geocoding, Places autocomplete + details, Maps JS loader. Zero Google API keys in consumer projects.",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
7
7
  "module": "./src/index.js",
@@ -17,13 +17,7 @@
17
17
  "dependencies": {
18
18
  "@sanvika/logger": ">=0.2"
19
19
  },
20
- "keywords": [
21
- "sanvika",
22
- "geolocation",
23
- "ip",
24
- "location",
25
- "india"
26
- ],
20
+ "keywords": ["sanvika", "geolocation", "ip", "location", "india"],
27
21
  "author": "Sanvika Production",
28
22
  "license": "UNLICENSED",
29
23
  "repository": {
@@ -33,4 +27,4 @@
33
27
  "publishConfig": {
34
28
  "access": "public"
35
29
  }
36
- }
30
+ }
@@ -1,15 +1,127 @@
1
1
  // packages/sanvika-geolocation-sdk/src/client/index.js
2
- // Client-side (browser) geolocation with HTML5 GPS + cache/IP fallback
3
- // Usage: import { getLocationPromise, getFullLocationDetails } from "@sanvika/geolocation/client"
2
+ // Client-side (browser) geolocation helpers.
3
+ // Usage: import { ... } from "@sanvika/geolocation/client"
4
+ //
5
+ // Env vars (read from process.env in Next.js / similar):
6
+ // NEXT_PUBLIC_GEOLOCATION_URL=https://geolocation.sanvikaproduction.com
7
+ // NEXT_PUBLIC_GEOLOCATION_CLIENT_ID=fapk
8
+ //
9
+ // Consumer projects NO LONGER need proxy routes for geolocation.
10
+ // All calls go directly to geolocation.sanvikaproduction.com via public clientId auth.
11
+ // Only getUserLocation / updateUserLocation still need FAPK routes (they use SA SSO auth).
4
12
 
5
13
  import { createLogger } from "@sanvika/logger";
6
14
  import { setWithTTL, getWithTTL, TTL } from "./localStorageTTL.js";
7
15
 
16
+ // ─── Direct service helpers (no consumer proxy routes needed) ────────────────
17
+
18
+ function _geoUrl() {
19
+ return (typeof process !== "undefined" && process.env?.NEXT_PUBLIC_GEOLOCATION_URL)
20
+ || "https://geolocation.sanvikaproduction.com";
21
+ }
22
+
23
+ function _clientId() {
24
+ return (typeof process !== "undefined" && process.env?.NEXT_PUBLIC_GEOLOCATION_CLIENT_ID) || "";
25
+ }
26
+
27
+ async function _servicePost(path, body) {
28
+ const clientId = _clientId();
29
+ const qs = clientId ? `?clientId=${encodeURIComponent(clientId)}` : "";
30
+ try {
31
+ const res = await fetch(`${_geoUrl()}${path}${qs}`, {
32
+ method: "POST",
33
+ headers: { "Content-Type": "application/json" },
34
+ body: JSON.stringify(body),
35
+ });
36
+ return res.ok ? res.json() : { success: false };
37
+ } catch {
38
+ return { success: false };
39
+ }
40
+ }
41
+
42
+ /**
43
+ * Reverse geocode lat/lng → address parts via geolocation service directly.
44
+ * Replaces fetch("/api/location/getLocationDetails") in consumer projects.
45
+ */
46
+ export async function reverseGeocode(latitude, longitude, opts = {}) {
47
+ const r = await _servicePost("/api/v1/geocode/reverse", { lat: latitude, lng: longitude });
48
+ if (!r.success || !r.found) return null;
49
+ return {
50
+ country: r.country || "",
51
+ state: r.normalizedState || r.state || "",
52
+ city: r.city || "",
53
+ nearbyLandmark: r.sublocality || "",
54
+ postalCode: r.postalCode || "",
55
+ fullAddress: r.fullAddress || "",
56
+ latitude: Number(latitude),
57
+ longitude: Number(longitude),
58
+ geoLocation: { type: "Point", coordinates: [Number(longitude), Number(latitude)] },
59
+ placeId: r.placeId || "",
60
+ source: opts.source || "reverse_geocode",
61
+ };
62
+ }
63
+
64
+ /**
65
+ * Forward geocode address → lat/lng via geolocation service directly.
66
+ * Replaces fetch("/api/location/getCoordinates") in consumer projects.
67
+ */
68
+ export async function forwardGeocode(address, { country = "IN" } = {}) {
69
+ return _servicePost("/api/v1/geocode/forward", { address, country });
70
+ }
71
+
72
+ /**
73
+ * IP-based location lookup via geolocation service directly from browser.
74
+ * Replaces fetch("/api/location/getIpLocation") in consumer projects.
75
+ * Browser's real IP is detected server-side by the geolocation service.
76
+ */
77
+ export async function getIpLocation() {
78
+ try {
79
+ const clientId = _clientId();
80
+ const qs = clientId ? `?clientId=${encodeURIComponent(clientId)}` : "";
81
+ const res = await fetch(`${_geoUrl()}/api/v1/locate${qs}`, {
82
+ method: "POST",
83
+ headers: { "Content-Type": "application/json" },
84
+ body: JSON.stringify({}),
85
+ });
86
+ if (!res.ok) return null;
87
+ const data = await res.json();
88
+ if (!data.success || !data.location) return null;
89
+ const l = data.location;
90
+ return { latitude: l.lat ?? null, longitude: l.lng ?? null, city: l.city || "Unknown", state: l.region || "", country: l.country || "India", source: "ip" };
91
+ } catch {
92
+ return null;
93
+ }
94
+ }
95
+
96
+ /**
97
+ * Places autocomplete via geolocation service directly.
98
+ * Replaces fetch("/api/location/places/autocomplete") in consumer projects.
99
+ */
100
+ export async function placesAutocomplete(input, { country = "IN", types = "geocode", language = "en", sessionToken } = {}) {
101
+ if (!input || input.trim().length < 2) return { success: true, predictions: [] };
102
+ return _servicePost("/api/v1/places/autocomplete", { input: input.trim(), country, types, language, sessionToken });
103
+ }
104
+
105
+ /**
106
+ * Place details by placeId via geolocation service directly.
107
+ * Replaces fetch("/api/location/places/details") in consumer projects.
108
+ */
109
+ export async function placesDetails(placeId, { language = "en", sessionToken } = {}) {
110
+ if (!placeId) return { success: false };
111
+ return _servicePost("/api/v1/places/details", { placeId, language, sessionToken });
112
+ }
113
+
114
+ // ─────────────────────────────────────────────────────────────────────────────
115
+
116
+ export {
117
+ loadGoogleMapsScript,
118
+ getMapsConfig,
119
+ resetMapsLoader,
120
+ } from "./loadGoogleMapsScript.js";
121
+
8
122
  const logger = createLogger({ namespace: "SanvikaGeolocation" });
9
123
  const LOCATION_CACHE_KEY = "sanvika_location_cache";
10
124
 
11
- // ─── Internal helpers ────────────────────────────────────────────────────────
12
-
13
125
  async function tryFallbacks(options) {
14
126
  if (options.useCachedFallback) {
15
127
  const cached = getWithTTL(LOCATION_CACHE_KEY);
@@ -27,26 +139,18 @@ async function tryFallbacks(options) {
27
139
 
28
140
  if (options.useIpFallback) {
29
141
  try {
30
- const res = await fetch("/api/location/getIpLocation");
31
- const data = await res.json();
32
- if (data.success && data.location) {
142
+ // Call geolocation service directly — no FAPK proxy needed
143
+ const ipLoc = await getIpLocation();
144
+ if (ipLoc?.latitude && ipLoc?.longitude) {
33
145
  logger.info("Using IP fallback for location");
34
- return {
35
- latitude: data.location.latitude,
36
- longitude: data.location.longitude,
37
- accuracy: 20000,
38
- source: "ip",
39
- timestamp: Date.now(),
40
- };
146
+ return { latitude: ipLoc.latitude, longitude: ipLoc.longitude, accuracy: 20000, source: "ip", timestamp: Date.now() };
41
147
  }
42
- } catch { /* silent — will throw below */ }
148
+ } catch { /* silent */ }
43
149
  }
44
150
 
45
151
  throw new Error("All location methods failed");
46
152
  }
47
153
 
48
- // ─── Main exports ────────────────────────────────────────────────────────────
49
-
50
154
  export function getLocationPromise(options = {}) {
51
155
  const finalOptions = {
52
156
  enableHighAccuracy: true,
@@ -111,17 +215,9 @@ export async function getFullLocationDetails(options = {}) {
111
215
  if (!locationData.latitude || !locationData.longitude) return locationData;
112
216
 
113
217
  try {
114
- const res = await fetch("/api/location/getLocationDetails", {
115
- method: "POST",
116
- headers: { "Content-Type": "application/json" },
117
- body: JSON.stringify({
118
- latitude: locationData.latitude,
119
- longitude: locationData.longitude,
120
- source: locationData.source,
121
- }),
122
- });
123
- const data = await res.json();
124
- if (data.success) return { ...data.location, source: locationData.source, timestamp: locationData.timestamp };
218
+ // Call geolocation service directly — no FAPK proxy needed
219
+ const details = await reverseGeocode(locationData.latitude, locationData.longitude, { source: locationData.source });
220
+ if (details) return { ...details, source: locationData.source, timestamp: locationData.timestamp };
125
221
  } catch (error) {
126
222
  logger.error("Error fetching location details", error);
127
223
  }
@@ -162,16 +258,11 @@ export function getCachedLocation() {
162
258
 
163
259
  export async function getCityFromCoordinates(latitude, longitude) {
164
260
  const cached = getCachedLocation();
165
- if (cached?.city) return cached.city;
166
-
261
+ if (cached?.address?.city) return cached.address.city;
167
262
  try {
168
- const res = await fetch("/api/location/getLocationDetails", {
169
- method: "POST",
170
- headers: { "Content-Type": "application/json" },
171
- body: JSON.stringify({ latitude, longitude, source: "city_fetch" }),
172
- });
173
- const data = await res.json();
174
- if (data.success && data.location?.city) return data.location.city;
263
+ // Call geolocation service directly — no FAPK proxy needed
264
+ const details = await reverseGeocode(latitude, longitude);
265
+ if (details?.city) return details.city;
175
266
  } catch (error) {
176
267
  logger.error("Error getting city from coordinates:", error);
177
268
  }
@@ -0,0 +1,121 @@
1
+ // packages/sanvika-geolocation-sdk/src/client/loadGoogleMapsScript.js
2
+ // Browser-side loader for Google Maps JS API.
3
+ // Fetches API key + Map ID from geolocation.sanvikaproduction.com (centralized),
4
+ // then injects the standard Google Maps script tag with the centrally-issued key.
5
+ //
6
+ // Usage:
7
+ // import { loadGoogleMapsScript } from "@sanvika/geolocation/client";
8
+ // await loadGoogleMapsScript({ libraries: ["places", "marker"] });
9
+ //
10
+ // Env vars (auto-read from process.env):
11
+ // NEXT_PUBLIC_GEOLOCATION_URL=https://geolocation.sanvikaproduction.com
12
+ // NEXT_PUBLIC_GEOLOCATION_CLIENT_ID=fapk
13
+
14
+ import { createLogger } from "@sanvika/logger";
15
+
16
+ const logger = createLogger({ namespace: "SanvikaGeolocation.MapsLoader" });
17
+
18
+ let inflight = null;
19
+ let cachedConfig = null;
20
+
21
+ function readEnv(key) {
22
+ try {
23
+ return (typeof process !== "undefined" && process.env?.[key]) || null;
24
+ } catch {
25
+ return null;
26
+ }
27
+ }
28
+
29
+ async function fetchMapsConfig({ apiBase, clientId }) {
30
+ if (cachedConfig) return cachedConfig;
31
+ const url = `${apiBase}/api/v1/mapsjs/config?clientId=${encodeURIComponent(clientId)}`;
32
+ const res = await fetch(url, { credentials: "omit" });
33
+ const data = await res.json().catch(() => ({}));
34
+ if (!res.ok || !data?.success) {
35
+ throw new Error(`mapsjs/config failed: ${data?.error || `HTTP_${res.status}`}`);
36
+ }
37
+ cachedConfig = data.config;
38
+ return cachedConfig;
39
+ }
40
+
41
+ export async function loadGoogleMapsScript({
42
+ libraries = ["places", "marker"],
43
+ version = "weekly",
44
+ apiBase: apiBaseInput,
45
+ clientId: clientIdInput,
46
+ } = {}) {
47
+ if (typeof window === "undefined") {
48
+ throw new Error("loadGoogleMapsScript must run in the browser");
49
+ }
50
+ if (window.google?.maps?.Map) return window.google;
51
+ if (inflight) return inflight;
52
+
53
+ const apiBase = (apiBaseInput || readEnv("NEXT_PUBLIC_GEOLOCATION_URL") || "").replace(/\/$/, "");
54
+ const clientId = clientIdInput || readEnv("NEXT_PUBLIC_GEOLOCATION_CLIENT_ID");
55
+ if (!apiBase || !clientId) {
56
+ throw new Error("loadGoogleMapsScript: NEXT_PUBLIC_GEOLOCATION_URL and NEXT_PUBLIC_GEOLOCATION_CLIENT_ID are required");
57
+ }
58
+
59
+ inflight = (async () => {
60
+ const config = await fetchMapsConfig({ apiBase, clientId });
61
+
62
+ return new Promise((resolve, reject) => {
63
+ const callbackName = `__sanvika_maps_cb_${Date.now()}`;
64
+ window[callbackName] = () => {
65
+ delete window[callbackName];
66
+ if (window.google?.maps?.Map) {
67
+ logger.info("Google Maps loaded", { libraries, mapId: !!config.mapId });
68
+ resolve(window.google);
69
+ } else {
70
+ reject(new Error("Maps loaded but window.google.maps.Map missing"));
71
+ }
72
+ };
73
+
74
+ const params = new URLSearchParams({
75
+ key: config.apiKey,
76
+ v: version,
77
+ libraries: libraries.join(","),
78
+ loading: "async",
79
+ callback: callbackName,
80
+ });
81
+ if (config.mapId) params.set("map_ids", config.mapId);
82
+
83
+ const existing = document.querySelector("script[data-sanvika-maps]");
84
+ if (existing) {
85
+ if (window.google?.maps?.Map) {
86
+ delete window[callbackName];
87
+ resolve(window.google);
88
+ }
89
+ return;
90
+ }
91
+
92
+ const script = document.createElement("script");
93
+ script.src = `https://maps.googleapis.com/maps/api/js?${params.toString()}`;
94
+ script.async = true;
95
+ script.defer = true;
96
+ script.dataset.sanvikaMaps = "1";
97
+ script.onerror = (e) => {
98
+ delete window[callbackName];
99
+ inflight = null;
100
+ reject(e);
101
+ };
102
+ document.head.appendChild(script);
103
+ });
104
+ })();
105
+
106
+ try {
107
+ return await inflight;
108
+ } catch (e) {
109
+ inflight = null;
110
+ throw e;
111
+ }
112
+ }
113
+
114
+ export function getMapsConfig() {
115
+ return cachedConfig;
116
+ }
117
+
118
+ export function resetMapsLoader() {
119
+ cachedConfig = null;
120
+ inflight = null;
121
+ }
@@ -1,13 +1,11 @@
1
1
  // packages/sanvika-geolocation-sdk/src/server/index.js
2
- // Server-side IP geolocation via ip-api.com (free, no API key, 45 req/min)
3
- // + Reverse geocoding: lat/lng Indian state/city via Google Geocoding API
4
-
5
- export {
6
- detectStateFromCoordinates,
7
- isUserInTargetStates,
8
- clearStateCache,
9
- getStateCacheStats,
10
- } from "./stateDetection.js";
2
+ // Server-side helpers all Google Maps + IP lookup calls route through
3
+ // geolocation.sanvikaproduction.com. Consumer projects don't need any
4
+ // Google API keys in their own env.
5
+ //
6
+ // Required env vars in consumer projects:
7
+ // GEOLOCATION_URL=https://geolocation.sanvikaproduction.com
8
+ // GEOLOCATION_CLIENT_SECRET=<plain text, registered in admin panel>
11
9
 
12
10
  export {
13
11
  INDIAN_STATES,
@@ -18,91 +16,116 @@ export {
18
16
  isValidIndianState,
19
17
  } from "./india.js";
20
18
 
19
+ export {
20
+ GeolocationServiceClient,
21
+ getServiceClient,
22
+ resetServiceClient,
23
+ } from "./serviceClient.js";
24
+
21
25
  import { createLogger } from "@sanvika/logger";
26
+ import { getServiceClient } from "./serviceClient.js";
27
+ import { normalizeStateName } from "./india.js";
22
28
 
23
29
  const logger = createLogger({ namespace: "SanvikaGeolocation" });
24
30
 
25
- const locationCache = new Map();
26
- const CACHE_TTL = 24 * 60 * 60 * 1000; // 24 hours
27
-
28
31
  export function isPrivateIp(ip) {
32
+ if (!ip) return true;
29
33
  const privateRanges = [
30
- /^127\./,
31
- /^10\./,
32
- /^172\.(1[6-9]|2[0-9]|3[0-1])\./,
33
- /^192\.168\./,
34
- /^::1$/,
35
- /^fe80:/i,
36
- /^fc00:/i,
37
- /^fd00:/i,
34
+ /^127\./, /^10\./, /^172\.(1[6-9]|2[0-9]|3[0-1])\./, /^192\.168\./,
35
+ /^::1$/, /^fe80:/i, /^fc00:/i, /^fd00:/i,
38
36
  ];
39
37
  return privateRanges.some((r) => r.test(ip));
40
38
  }
41
39
 
42
- function getDefaultLocation() {
40
+ function defaultLocation() {
43
41
  return {
44
- city: "Unknown",
45
- state: "Unknown",
46
- country: "India",
47
- countryCode: "IN",
48
- zipCode: null,
49
- latitude: null,
50
- longitude: null,
51
- timezone: "Asia/Kolkata",
52
- source: "default",
42
+ city: "Unknown", state: "Unknown", country: "India", countryCode: "IN",
43
+ zipCode: null, latitude: null, longitude: null,
44
+ timezone: "Asia/Kolkata", source: "default",
53
45
  };
54
46
  }
55
47
 
48
+ /** Resolve approximate location from an IP via geolocation service (cached server-side). */
56
49
  export async function getLocationFromIp(ip) {
57
- if (!ip || ip === "unknown" || isPrivateIp(ip)) {
58
- return getDefaultLocation();
59
- }
50
+ if (!ip || ip === "unknown" || isPrivateIp(ip)) return defaultLocation();
51
+ const r = await getServiceClient().request("/api/v1/locate", { body: { ip } });
52
+ if (!r.success || !r.location) return defaultLocation();
53
+ const l = r.location;
54
+ return {
55
+ city: l.city || "Unknown",
56
+ state: l.region || "Unknown",
57
+ country: l.country || "India",
58
+ countryCode: l.countryCode || "IN",
59
+ zipCode: null,
60
+ latitude: l.lat ?? null,
61
+ longitude: l.lng ?? null,
62
+ timezone: l.timezone || "Asia/Kolkata",
63
+ source: r.source || "lookup",
64
+ };
65
+ }
60
66
 
61
- const cached = locationCache.get(ip);
62
- if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
63
- return cached.data;
67
+ /** Reverse geocode lat/lng → address parts (proxied via geolocation service, cached). */
68
+ export async function reverseGeocode(latitude, longitude) {
69
+ const lat = typeof latitude === "string" ? parseFloat(latitude) : latitude;
70
+ const lng = typeof longitude === "string" ? parseFloat(longitude) : longitude;
71
+ if (!Number.isFinite(lat) || !Number.isFinite(lng)) {
72
+ return { success: false, error: "INVALID_COORDINATES" };
64
73
  }
74
+ return getServiceClient().request("/api/v1/geocode/reverse", { body: { lat, lng } });
75
+ }
65
76
 
66
- try {
67
- const response = await fetch(
68
- `http://ip-api.com/json/${ip}?fields=status,message,country,countryCode,region,regionName,city,zip,lat,lon,timezone`,
69
- {
70
- method: "GET",
71
- headers: { Accept: "application/json" },
72
- signal: AbortSignal.timeout(3000),
73
- }
74
- );
75
-
76
- if (!response.ok) throw new Error(`ip-api returned ${response.status}`);
77
-
78
- const data = await response.json();
77
+ /** Forward geocode address → lat/lng (proxied, cached). */
78
+ export async function forwardGeocode(address, { country = "IN" } = {}) {
79
+ if (!address) return { success: false, error: "ADDRESS_REQUIRED" };
80
+ return getServiceClient().request("/api/v1/geocode/forward", { body: { address, country } });
81
+ }
79
82
 
80
- if (data.status === "fail") {
81
- logger.warn("IP lookup failed", { ip, message: data.message });
82
- return getDefaultLocation();
83
- }
83
+ /** Server-side Places autocomplete — input → predictions[]. */
84
+ export async function placesAutocomplete(input, { country = "IN", types = "geocode", language = "en", sessionToken } = {}) {
85
+ if (!input) return { success: false, error: "INPUT_REQUIRED" };
86
+ return getServiceClient().request("/api/v1/places/autocomplete", {
87
+ body: { input, country, types, language, sessionToken },
88
+ });
89
+ }
84
90
 
85
- const location = {
86
- city: data.city || "Unknown",
87
- state: data.regionName || data.region || "Unknown",
88
- country: data.country || "India",
89
- countryCode: data.countryCode || "IN",
90
- zipCode: data.zip || null,
91
- latitude: data.lat || null,
92
- longitude: data.lon || null,
93
- timezone: data.timezone || "Asia/Kolkata",
94
- source: "ip_api",
95
- };
91
+ /** Server-side Place details by placeId. */
92
+ export async function placesDetails(placeId, { language = "en", fields, sessionToken } = {}) {
93
+ if (!placeId) return { success: false, error: "PLACE_ID_REQUIRED" };
94
+ return getServiceClient().request("/api/v1/places/details", {
95
+ body: { placeId, language, fields, sessionToken },
96
+ });
97
+ }
96
98
 
97
- locationCache.set(ip, { data: location, timestamp: Date.now() });
98
- logger.info("IP location resolved", { city: location.city });
99
- return location;
100
- } catch (error) {
101
- logger.warn("IP geolocation failed", { error: error.message });
102
- return getDefaultLocation();
99
+ /**
100
+ * Detect Indian state + city from coordinates.
101
+ * Now proxied through reverseGeocode (which calls geolocation service) — no Google key in consumer env.
102
+ */
103
+ export async function detectStateFromCoordinates(latitude, longitude) {
104
+ const lat = typeof latitude === "string" ? parseFloat(latitude) : latitude;
105
+ const lng = typeof longitude === "string" ? parseFloat(longitude) : longitude;
106
+ if (!Number.isFinite(lat) || !Number.isFinite(lng)) {
107
+ return { state: null, city: null, fromCache: false };
103
108
  }
109
+ const r = await reverseGeocode(lat, lng);
110
+ if (!r.success || !r.found) return { state: null, city: null, fromCache: false };
111
+ const state = r.normalizedState || normalizeStateName(r.state);
112
+ if (state) {
113
+ logger.info("State detected", { state, city: r.city });
114
+ } else if (r.state) {
115
+ logger.warn("State not recognized as Indian", { rawState: r.state });
116
+ }
117
+ return { state, city: r.city || null, fromCache: false };
104
118
  }
105
119
 
106
- export function clearLocationCache() {
107
- locationCache.clear();
120
+ export async function isUserInTargetStates(latitude, longitude, targetStates) {
121
+ if (!Array.isArray(targetStates) || targetStates.length === 0) return false;
122
+ const { state } = await detectStateFromCoordinates(latitude, longitude);
123
+ if (!state) return false;
124
+ const normalized = targetStates.map(normalizeStateName).filter(Boolean);
125
+ return normalized.includes(state);
108
126
  }
127
+
128
+ // Legacy no-ops kept for backwards compatibility.
129
+ export function clearLocationCache() {}
130
+ export function clearStateCache() {}
131
+ export function getStateCacheStats() { return { keys: 0 }; }
@@ -0,0 +1,69 @@
1
+ // packages/sanvika-geolocation-sdk/src/server/serviceClient.js
2
+ // HTTP client for geolocation.sanvikaproduction.com — used by all server-side helpers.
3
+ // Env vars: GEOLOCATION_URL, GEOLOCATION_CLIENT_SECRET (or pass to constructor).
4
+
5
+ const DEFAULT_TIMEOUT_MS = 6000;
6
+
7
+ function readEnv(key) {
8
+ try {
9
+ return (typeof process !== "undefined" && process.env?.[key]) || null;
10
+ } catch {
11
+ return null;
12
+ }
13
+ }
14
+
15
+ export class GeolocationServiceClient {
16
+ #url;
17
+ #secret;
18
+
19
+ constructor({ url, secret } = {}) {
20
+ const finalUrl = url || readEnv("GEOLOCATION_URL");
21
+ const finalSecret = secret || readEnv("GEOLOCATION_CLIENT_SECRET");
22
+ if (!finalUrl || !finalSecret) {
23
+ throw new Error("@sanvika/geolocation/server: GEOLOCATION_URL and GEOLOCATION_CLIENT_SECRET are required");
24
+ }
25
+ this.#url = finalUrl.replace(/\/$/, "");
26
+ this.#secret = finalSecret;
27
+ }
28
+
29
+ get baseUrl() { return this.#url; }
30
+
31
+ async request(path, { method = "POST", body, query } = {}) {
32
+ const controller = new AbortController();
33
+ const timer = setTimeout(() => controller.abort(), DEFAULT_TIMEOUT_MS);
34
+
35
+ const qs = query ? "?" + new URLSearchParams(query).toString() : "";
36
+ const init = {
37
+ method,
38
+ headers: { "x-client-secret": this.#secret },
39
+ signal: controller.signal,
40
+ };
41
+ if (body !== undefined) {
42
+ init.headers["Content-Type"] = "application/json";
43
+ init.body = JSON.stringify(body);
44
+ }
45
+
46
+ try {
47
+ const res = await fetch(`${this.#url}${path}${qs}`, init);
48
+ const json = await res.json().catch(() => ({}));
49
+ if (!res.ok || !json.success) {
50
+ return { success: false, status: res.status, error: json?.error || `HTTP_${res.status}` };
51
+ }
52
+ return { success: true, ...json };
53
+ } catch (err) {
54
+ return { success: false, error: err.name === "AbortError" ? "TIMEOUT" : err.message };
55
+ } finally {
56
+ clearTimeout(timer);
57
+ }
58
+ }
59
+ }
60
+
61
+ let _instance = null;
62
+ export function getServiceClient() {
63
+ if (!_instance) _instance = new GeolocationServiceClient();
64
+ return _instance;
65
+ }
66
+
67
+ export function resetServiceClient() {
68
+ _instance = null;
69
+ }
@@ -1,112 +0,0 @@
1
- // Reverse geocoding: lat/lng → Indian state + city via Google Geocoding API
2
- // Supports GOOGLE_MAPS_API_KEY (server-safe) or NEXT_PUBLIC_GOOGLE_MAPS_API_KEY (Next.js)
3
-
4
- import { createLogger } from "@sanvika/logger";
5
- import { normalizeStateName } from "./india.js";
6
-
7
- const logger = createLogger({ namespace: "SanvikaGeolocation.StateDetection" });
8
-
9
- // In-memory cache — key: "lat_lng" rounded to 2dp (~1.1km accuracy)
10
- const stateCache = new Map();
11
- const CACHE_TTL = 24 * 60 * 60 * 1000; // 24 hours
12
-
13
- function getCacheKey(lat, lng) {
14
- return `${Math.round(lat * 100) / 100}_${Math.round(lng * 100) / 100}`;
15
- }
16
-
17
- function getApiKey() {
18
- return (
19
- (typeof process !== "undefined" && process.env?.GOOGLE_MAPS_API_KEY) ||
20
- (typeof process !== "undefined" && process.env?.NEXT_PUBLIC_GOOGLE_MAPS_API_KEY) ||
21
- null
22
- );
23
- }
24
-
25
- export async function detectStateFromCoordinates(latitude, longitude) {
26
- const lat = typeof latitude === "string" ? parseFloat(latitude) : latitude;
27
- const lng = typeof longitude === "string" ? parseFloat(longitude) : longitude;
28
-
29
- if (!lat || !lng || !Number.isFinite(lat) || !Number.isFinite(lng)) {
30
- logger.warn("Invalid coordinates", { latitude, longitude });
31
- return { state: null, city: null, fromCache: false };
32
- }
33
-
34
- const key = getCacheKey(lat, lng);
35
- const cached = stateCache.get(key);
36
- if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
37
- return { state: cached.state, city: cached.city, fromCache: true };
38
- }
39
-
40
- const apiKey = getApiKey();
41
- if (!apiKey) {
42
- logger.error("Google Maps API key not configured (set GOOGLE_MAPS_API_KEY or NEXT_PUBLIC_GOOGLE_MAPS_API_KEY)");
43
- return { state: null, city: null, fromCache: false };
44
- }
45
-
46
- try {
47
- const url = new URL("https://maps.googleapis.com/maps/api/geocode/json");
48
- url.searchParams.set("latlng", `${lat},${lng}`);
49
- url.searchParams.set("key", apiKey);
50
- url.searchParams.set("result_type", "administrative_area_level_1|locality");
51
-
52
- const res = await fetch(url.toString(), {
53
- signal: AbortSignal.timeout(5000),
54
- });
55
-
56
- if (!res.ok) throw new Error(`Geocoding API returned ${res.status}`);
57
-
58
- const data = await res.json();
59
-
60
- if (data.status !== "OK") {
61
- logger.warn("Geocoding API non-OK status", { status: data.status, lat, lng });
62
- return { state: null, city: null, fromCache: false };
63
- }
64
-
65
- let rawState = null;
66
- let city = null;
67
-
68
- for (const result of (data.results || [])) {
69
- for (const component of (result.address_components || [])) {
70
- if (component.types.includes("administrative_area_level_1") && !rawState) {
71
- rawState = component.long_name;
72
- }
73
- if (component.types.includes("locality") && !city) {
74
- city = component.long_name;
75
- }
76
- }
77
- if (rawState && city) break;
78
- }
79
-
80
- const state = normalizeStateName(rawState);
81
-
82
- if (state) {
83
- logger.info("State detected", { state, city, rawState });
84
- } else if (rawState) {
85
- logger.warn("State not recognized as Indian state", { rawState });
86
- }
87
-
88
- stateCache.set(key, { state, city, rawState, timestamp: Date.now() });
89
- return { state, city, fromCache: false };
90
- } catch (error) {
91
- logger.error("State detection failed", { error: error.message, lat, lng });
92
- return { state: null, city: null, fromCache: false };
93
- }
94
- }
95
-
96
- export async function isUserInTargetStates(latitude, longitude, targetStates) {
97
- if (!Array.isArray(targetStates) || targetStates.length === 0) return false;
98
- const { state } = await detectStateFromCoordinates(latitude, longitude);
99
- if (!state) return false;
100
- const normalized = targetStates.map(normalizeStateName).filter(Boolean);
101
- const match = normalized.includes(state);
102
- logger.debug("State targeting", { state, targetStates: normalized, match });
103
- return match;
104
- }
105
-
106
- export function clearStateCache() {
107
- stateCache.clear();
108
- }
109
-
110
- export function getStateCacheStats() {
111
- return { keys: stateCache.size };
112
- }