feat: implement billing, usage tracking, and administrative geocoding features in API with infrastructure updates

This commit is contained in:
Hamza-Ayed
2026-07-28 04:43:23 +03:00
parent 2f7c2066f7
commit dc9d546733
133 changed files with 3599 additions and 356 deletions
+240 -52
View File
@@ -12,6 +12,7 @@ var __param = (this && this.__param) || function (paramIndex, decorator) {
return function (target, key) { decorator(target, key, paramIndex); }
};
var GeocodingService_1;
var _a, _b, _c, _d, _e;
Object.defineProperty(exports, "__esModule", { value: true });
exports.GeocodingService = void 0;
const common_1 = require("@nestjs/common");
@@ -73,9 +74,16 @@ let GeocodingService = GeocodingService_1 = class GeocodingService {
}
async searchPlaces(query, lat, lon, radius = 20000, country) {
try {
const cleanQuery = query.trim();
let cleanQuery = query.trim();
if (!cleanQuery)
return { results: [] };
let relativePrefix = '';
const relativeQueryRegex = /^(قرب|بالقرب من|قريب من|عند|بجانب|جنب|حد|بجوار|مقابل|قبال|خلف|ورا|وراء)\s+(.+)$/i;
const match = cleanQuery.match(relativeQueryRegex);
if (match) {
relativePrefix = match[1];
cleanQuery = match[2];
}
const hasLocation = lat !== undefined && lon !== undefined;
const geoSegment = hasLocation ? `${lat.toFixed(2)}_${lon.toFixed(2)}` : 'global';
const cacheKey = `geo_search:${country || 'auto'}:${geoSegment}:${cleanQuery.toLowerCase()}`;
@@ -83,56 +91,93 @@ let GeocodingService = GeocodingService_1 = class GeocodingService {
if (cached)
return { results: cached, source: 'cache_hit' };
let targetRegion = country?.toLowerCase() || this.identifyRegion(lat, lon);
const primaryTables = targetRegion && ['syria', 'egypt', 'jordan'].includes(targetRegion)
? [`places_${targetRegion}`] : ['places_jordan', 'places_syria', 'places_egypt'];
const queryPromises = [];
primaryTables.forEach(tableName => {
const repo = this.getRepoByTableName(tableName);
queryPromises.push(repo.query(`
SELECT
p.id, p.name, p.name_ar, p.name_en, p.category,
n.name_ar as neighbourhood, d.name_ar as district, g.name_ar as governorate,
p.latitude, p.longitude, p.address, '${tableName.replace('places_', '')}' as region, 'user_place' as source,
CASE WHEN $2::float IS NOT NULL THEN ST_DistanceSphere(p.location, ST_SetSRID(ST_MakePoint($3::float, $2::float), 4326)) ELSE 0 END as distance,
GREATEST(similarity(COALESCE(p.name_ar, ''), $1), similarity(COALESCE(p.name, ''), $1)) as relevance
FROM ${tableName} p
LEFT JOIN neighborhood_polygons n ON p.neighborhood_id = n.id
LEFT JOIN admin_boundaries d ON p.district_id = d.id
LEFT JOIN admin_boundaries g ON p.governorate_id = g.id
WHERE (p.name_ar % $1 OR p.name % $1)
AND ($2::float IS NULL OR ST_DistanceSphere(p.location, ST_SetSRID(ST_MakePoint($3::float, $2::float), 4326)) <= $4)
ORDER BY (p.name_ar <-> $1) ASC LIMIT 10
`, [cleanQuery, lat || null, lon || null, radius]));
});
queryPromises.push(this.osmPointsRepository.query(`
const [normalizedQueryRes] = await this.osmPointsRepository.query(`SELECT normalize_arabic($1) as nq`, [cleanQuery]);
const normalizedQuery = normalizedQueryRes?.nq || cleanQuery.toLowerCase();
let queryParams = [normalizedQuery];
let locationCondition = '';
if (hasLocation) {
locationCondition = `AND ST_DistanceSphere(location, ST_SetSRID(ST_MakePoint($3::float, $2::float), 4326)) <= $4`;
queryParams.push(lat, lon, radius);
}
let regionCondition = '';
if (targetRegion && ['syria', 'jordan', 'egypt'].includes(targetRegion)) {
regionCondition = `AND (region = '${targetRegion}' OR region = 'global')`;
}
const sqlQuery = `
SELECT
o.osm_id as id, o.name, o.name_ar, o.name_en, COALESCE(o.amenity, o.shop, 'place') as category,
o.latitude, o.longitude, o.addr_street as address, 'osm_global' as source,
CASE WHEN $2::float IS NOT NULL THEN ST_DistanceSphere(o.geom, ST_SetSRID(ST_MakePoint($3::float, $2::float), 4326)) ELSE 0 END as distance,
GREATEST(similarity(COALESCE(o.name, ''), $1), similarity(COALESCE(o.name_ar, ''), $1)) as relevance
FROM osm_points_with_area o
WHERE (o.name % $1 OR o.name_ar % $1)
AND ($2::float IS NULL OR ST_DistanceSphere(o.geom, ST_SetSRID(ST_MakePoint($3::float, $2::float), 4326)) <= $4)
ORDER BY (o.name <-> $1) ASC LIMIT 10
`, [cleanQuery, lat || null, lon || null, radius]));
const executionResults = await Promise.race([
Promise.allSettled(queryPromises),
id, name, name_ar, category,
'' as neighbourhood, '' as district, '' as governorate,
latitude, longitude, address, region, source, popularity_score,
${hasLocation ? 'ST_DistanceSphere(location, ST_SetSRID(ST_MakePoint($3::float, $2::float), 4326))' : '0'} as distance,
similarity(normalized_name, $1) as relevance
FROM unified_search_index
WHERE normalized_name % $1
${locationCondition}
${regionCondition}
ORDER BY (normalized_name <-> $1) ASC
LIMIT 50
`;
const allResults = await Promise.race([
this.osmPointsRepository.query(sqlQuery, queryParams),
new Promise((_, reject) => setTimeout(() => reject(new Error('QUERY_TIMEOUT')), this.DB_TIMEOUT_MS))
]).catch(e => {
this.logger.warn(`Search optimization threshold hit: ${e.message}`);
return [];
});
let allResults = [];
if (Array.isArray(executionResults)) {
executionResults.forEach(res => {
if (res.status === 'fulfilled' && res.value)
allResults.push(...res.value);
});
const formatted = this.formatResults(allResults, hasLocation, relativePrefix);
const placeIds = formatted.map(r => r.id);
if (placeIds.length > 0) {
try {
const gates = await this.osmPointsRepository.query(`SELECT place_id, gate_name_ar, gate_name_en, latitude, longitude, is_main_gate
FROM place_gates
WHERE place_id = ANY($1)`, [placeIds]);
if (gates.length > 0) {
formatted.forEach(r => {
const placeGates = gates.filter((g) => g.place_id === r.id).map((g) => ({
name_ar: g.gate_name_ar,
name_en: g.gate_name_en,
latitude: parseFloat(g.latitude),
longitude: parseFloat(g.longitude),
is_main_gate: g.is_main_gate
}));
if (placeGates.length > 0) {
r.gates = placeGates;
}
});
}
}
catch (err) {
this.logger.warn('Failed to fetch POI gates, table might not exist yet.');
}
}
const formatted = this.formatResults(allResults, hasLocation);
if (formatted.length > 0) {
await this.cacheManager.set(cacheKey, formatted, 3600000);
}
else {
this.logFailedSearch(cleanQuery, normalizedQuery, targetRegion, lat, lon).catch(err => {
this.logger.error('Failed to log zero-result search:', err);
});
try {
const whereClause = regionCondition ? `WHERE ${regionCondition.substring(4)}` : '';
const fallbackQuery = `
SELECT name_ar, name, (normalized_name <-> $1) as dist
FROM unified_search_index
${whereClause}
ORDER BY normalized_name <-> $1 ASC
LIMIT 1
`;
const suggestions = await this.osmPointsRepository.query(fallbackQuery, [normalizedQuery]);
if (suggestions.length > 0 && suggestions[0].dist < 0.6) {
return {
results: [],
did_you_mean: suggestions[0].name_ar || suggestions[0].name
};
}
}
catch (err) {
this.logger.warn('Did You Mean fallback failed: ' + err.message);
}
}
return { results: formatted };
}
catch (e) {
@@ -140,12 +185,15 @@ let GeocodingService = GeocodingService_1 = class GeocodingService {
return { results: [] };
}
}
formatResults(results, hasLocation) {
formatResults(results, hasLocation, relativePrefix = '') {
const seenStreets = new Set();
const maxPopularity = Math.max(...results.map(r => r.popularity_score || 10), 100);
return results
.map(r => {
const textScore = Number(r.relevance);
const popularityScore = (r.popularity_score || 10) / maxPopularity;
const proximityBonus = hasLocation ? Math.max(0, 1 - (Number(r.distance) / 10000)) : 0;
const totalScore = (Number(r.relevance) * 0.7) + (proximityBonus * 0.3);
const totalScore = (textScore * 0.5) + (popularityScore * 0.3) + (proximityBonus * 0.2);
return { ...r, totalScore };
})
.sort((a, b) => b.totalScore - a.totalScore)
@@ -162,8 +210,12 @@ let GeocodingService = GeocodingService_1 = class GeocodingService {
.map(r => {
const addressParts = [r.neighbourhood, r.district, r.governorate].filter(Boolean);
const full_address = addressParts.length > 0 ? addressParts.join('، ') : (r.address || '');
const nameAr = r.name_ar || r.name;
const displayName = relativePrefix ? `${relativePrefix} ${nameAr}` : nameAr;
return {
...r,
name: displayName,
name_ar: displayName,
latitude: parseFloat(r.latitude),
longitude: parseFloat(r.longitude),
distance_km: r.distance ? (Number(r.distance) / 1000).toFixed(2) : null,
@@ -179,11 +231,78 @@ let GeocodingService = GeocodingService_1 = class GeocodingService {
return this.placesJordanRepository;
return this.placesSyriaRepository;
}
async logFailedSearch(query, normalizedQuery, country, lat, lon) {
const q = `
INSERT INTO failed_searches (query_text, normalized_query, country, latitude, longitude)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (normalized_query, COALESCE(country, 'global'))
DO UPDATE SET search_count = failed_searches.search_count + 1, last_seen_at = CURRENT_TIMESTAMP;
`;
await this.osmPointsRepository.query(q, [query, normalizedQuery, country || null, lat || null, lon || null]);
}
async autocomplete(query, country) {
try {
let cleanQuery = query.trim();
if (cleanQuery.length < 2)
return { results: [] };
let relativePrefix = '';
const relativeQueryRegex = /^(قرب|بالقرب من|قريب من|عند|بجانب|جنب|حد|بجوار|مقابل|قبال|خلف|ورا|وراء)\s+(.+)$/i;
const match = cleanQuery.match(relativeQueryRegex);
if (match) {
relativePrefix = match[1];
cleanQuery = match[2];
if (cleanQuery.length < 2)
return { results: [] };
}
const targetRegion = country?.toLowerCase();
const cacheKey = `geo_auto:${targetRegion || 'auto'}:${cleanQuery.toLowerCase()}`;
const cached = await this.cacheManager.get(cacheKey);
if (cached)
return { results: cached, source: 'cache_hit' };
const [normalizedQueryRes] = await this.osmPointsRepository.query(`SELECT normalize_arabic($1) as nq`, [cleanQuery]);
const normalizedQuery = normalizedQueryRes?.nq || cleanQuery.toLowerCase();
let queryParams = [`${normalizedQuery}%`];
let regionCondition = '';
if (targetRegion && ['syria', 'jordan', 'egypt'].includes(targetRegion)) {
regionCondition = `AND (region = '${targetRegion}' OR region = 'global')`;
}
const sqlQuery = `
SELECT
id, name, name_ar, category, region, source, address
FROM unified_search_index
WHERE normalized_name LIKE $1
${regionCondition}
ORDER BY LENGTH(normalized_name) ASC
LIMIT 7
`;
const results = await this.osmPointsRepository.query(sqlQuery, queryParams);
const formatted = results.map(r => {
const nameAr = r.name_ar || r.name;
const displayName = relativePrefix ? `${relativePrefix} ${nameAr}` : nameAr;
return {
id: r.id,
name: displayName,
category: r.category,
region: r.region,
address: r.address
};
});
if (formatted.length > 0) {
await this.cacheManager.set(cacheKey, formatted, 3600000);
}
return { results: formatted };
}
catch (e) {
this.logger.error('Autocomplete failed:', e);
return { results: [] };
}
}
async reverseGeocode(lat, lng) {
try {
const repo = this.getRepositoryForCoords(lat, lng);
const tableName = this.getTableNameForRepo(repo);
const query = `
const queryPromises = [];
queryPromises.push(repo.query(`
SELECT
p.id, p.name, p.name_ar, p.category,
n.name_ar as neighbourhood,
@@ -196,9 +315,81 @@ let GeocodingService = GeocodingService_1 = class GeocodingService {
LEFT JOIN admin_boundaries d ON p.sub_district_id = d.id
LEFT JOIN admin_boundaries g ON p.governorate_id = g.id
WHERE p.location IS NOT NULL
ORDER BY p.location::geometry <-> ST_SetSRID(ST_MakePoint($1::float, $2::float), 4326) ASC LIMIT 3
`;
return await repo.query(query, [lng, lat]);
ORDER BY p.location::geometry <-> ST_SetSRID(ST_MakePoint($1::float, $2::float), 4326) ASC LIMIT 5
`, [lng, lat]));
queryPromises.push(repo.query(`
SELECT
o.osm_id::text as id, o.name, o.name_ar, COALESCE(o.amenity, o.shop, 'place') as category,
'' as neighbourhood, '' as district, '' as governorate,
o.latitude, o.longitude, o.addr_street as address, 'osm_global' as source,
ST_DistanceSphere(o.geom, ST_SetSRID(ST_MakePoint($1::float, $2::float), 4326)) as distance
FROM osm_points_with_area o
WHERE o.geom IS NOT NULL AND (o.name IS NOT NULL OR o.name_ar IS NOT NULL)
ORDER BY o.geom <-> ST_SetSRID(ST_MakePoint($1::float, $2::float), 4326) ASC LIMIT 5
`, [lng, lat]));
queryPromises.push(repo.query(`
SELECT
id::text, COALESCE(names->>'primary', names->>'common', 'Street') as name, COALESCE(names->>'primary', names->>'common', 'Street') as name_ar, 'street' as category,
'' as neighbourhood, '' as district, '' as governorate,
ST_Y(ST_Centroid(location))::text as latitude, ST_X(ST_Centroid(location))::text as longitude, '' as address, 'overture_global' as source,
ST_DistanceSphere(location, ST_SetSRID(ST_MakePoint($1::float, $2::float), 4326)) as distance
FROM overture_segment
WHERE location IS NOT NULL AND (names->>'primary' IS NOT NULL OR names->>'common' IS NOT NULL)
ORDER BY location <-> ST_SetSRID(ST_MakePoint($1::float, $2::float), 4326) ASC LIMIT 5
`, [lng, lat]));
queryPromises.push(repo.query(`
SELECT
id::text, COALESCE(names->>'primary', names->>'common', 'Building') as name, COALESCE(names->>'primary', names->>'common', 'Building') as name_ar, 'building' as category,
'' as neighbourhood, '' as district, '' as governorate,
ST_Y(ST_Centroid(location))::text as latitude, ST_X(ST_Centroid(location))::text as longitude, '' as address, 'overture_global' as source,
ST_DistanceSphere(location, ST_SetSRID(ST_MakePoint($1::float, $2::float), 4326)) as distance
FROM overture_building
WHERE location IS NOT NULL AND (names->>'primary' IS NOT NULL OR names->>'common' IS NOT NULL)
ORDER BY location <-> ST_SetSRID(ST_MakePoint($1::float, $2::float), 4326) ASC LIMIT 5
`, [lng, lat]));
queryPromises.push(repo.query(`
SELECT
id::text, COALESCE(names->>'primary', names->>'common', 'Place') as name, COALESCE(names->>'primary', names->>'common', 'Place') as name_ar, 'place' as category,
'' as neighbourhood, '' as district, '' as governorate,
ST_Y(ST_Centroid(location))::text as latitude, ST_X(ST_Centroid(location))::text as longitude, '' as address, 'overture_global' as source,
ST_DistanceSphere(location, ST_SetSRID(ST_MakePoint($1::float, $2::float), 4326)) as distance
FROM overture_place
WHERE location IS NOT NULL AND (names->>'primary' IS NOT NULL OR names->>'common' IS NOT NULL)
ORDER BY location <-> ST_SetSRID(ST_MakePoint($1::float, $2::float), 4326) ASC LIMIT 5
`, [lng, lat]));
const results = await Promise.allSettled(queryPromises);
let allResults = [];
results.forEach(res => {
if (res.status === 'fulfilled' && res.value)
allResults.push(...res.value);
});
return allResults
.sort((a, b) => Number(a.distance) - Number(b.distance))
.slice(0, 5)
.map(r => {
const distance = Number(r.distance);
const fullAddressParts = [r.name_ar || r.name, r.address, r.neighbourhood, r.district, r.governorate].filter(Boolean);
let humanReadable = r.name_ar || r.name;
if (distance <= 20) {
humanReadable = r.name_ar || r.name;
}
else if (distance <= 70 && r.category !== 'street') {
const streetName = r.address ? `، ${r.address}` : '';
humanReadable = `أمام ${r.name_ar || r.name}${streetName}`;
}
else if (r.category === 'street' || distance > 70) {
const streetPart = r.category === 'street' ? (r.name_ar || r.name) : (r.address || r.name_ar || r.name);
const districtPart = r.neighbourhood || r.district || '';
humanReadable = [streetPart, districtPart].filter(Boolean).join('، ');
}
return {
...r,
latitude: parseFloat(r.latitude),
longitude: parseFloat(r.longitude),
human_readable_address: humanReadable,
full_address: fullAddressParts.join('، ')
};
});
}
catch (error) {
this.logger.error('Reverse geocoding error:', error);
@@ -259,6 +450,7 @@ let GeocodingService = GeocodingService_1 = class GeocodingService {
UNION ALL SELECT id::text, name_ar as name, category, latitude, longitude, address, 'user' as region FROM places_egypt
UNION ALL SELECT id::text, COALESCE(names->>'primary', names->>'common', 'Building') as name, 'building' as category, ST_Y(ST_Centroid(location)) as latitude, ST_X(ST_Centroid(location)) as longitude, '' as address, 'overture' as region FROM overture_building WHERE names->>'primary' IS NOT NULL LIMIT 500
UNION ALL SELECT id::text, COALESCE(names->>'primary', names->>'common', 'Street') as name, 'street' as category, ST_Y(ST_Centroid(location)) as latitude, ST_X(ST_Centroid(location)) as longitude, '' as address, 'overture' as region FROM overture_segment WHERE names->>'primary' IS NOT NULL LIMIT 500
UNION ALL SELECT id::text, COALESCE(names->>'primary', names->>'common', 'Place') as name, 'place' as category, ST_Y(ST_Centroid(location)) as latitude, ST_X(ST_Centroid(location)) as longitude, '' as address, 'overture' as region FROM overture_place WHERE names->>'primary' IS NOT NULL LIMIT 500
`;
const res = await this.placesSyriaRepository.query(q);
const features = res.map(p => ({
@@ -299,10 +491,6 @@ exports.GeocodingService = GeocodingService = GeocodingService_1 = __decorate([
__param(3, (0, typeorm_1.InjectRepository)(osm_area_entity_1.OsmArea)),
__param(4, (0, typeorm_1.InjectRepository)(osm_point_with_area_entity_1.OsmPointWithArea)),
__param(5, (0, common_1.Inject)(cache_manager_1.CACHE_MANAGER)),
__metadata("design:paramtypes", [typeorm_2.Repository,
typeorm_2.Repository,
typeorm_2.Repository,
typeorm_2.Repository,
typeorm_2.Repository, Object])
__metadata("design:paramtypes", [typeof (_a = typeof typeorm_2.Repository !== "undefined" && typeorm_2.Repository) === "function" ? _a : Object, typeof (_b = typeof typeorm_2.Repository !== "undefined" && typeorm_2.Repository) === "function" ? _b : Object, typeof (_c = typeof typeorm_2.Repository !== "undefined" && typeorm_2.Repository) === "function" ? _c : Object, typeof (_d = typeof typeorm_2.Repository !== "undefined" && typeorm_2.Repository) === "function" ? _d : Object, typeof (_e = typeof typeorm_2.Repository !== "undefined" && typeorm_2.Repository) === "function" ? _e : Object, Object])
], GeocodingService);
//# sourceMappingURL=geocoding.service.js.map