All files / routes/marketplace localities.routes.ts

0% Statements 0/38
0% Branches 0/21
0% Functions 0/8
0% Lines 0/33

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149                                                                                                                                                                                                                                                                                                         
// Locality lookup endpoint — resolves a free-text name, a 6-digit pincode, or
// a slug into one or more matching localities. The chatgpt-app's
// `lookup_locality` MCP tool is the primary consumer; the marketplace uses
// the same shape for any "search by neighborhood" UX in the future.
 
import { Hono } from "hono";
import type { ContextVariables } from "../../middleware";
import { handleError, success } from "../../lib/response";
import {
	resolveByName,
	resolveByPincode,
	resolveBySlug,
	type LocalityLookupRow,
	type LocalityMatch,
} from "../../lib/locality-resolver";
import { localities, zones, cities } from "../../db/schema";
import { inArray } from "drizzle-orm";
 
type Env = {
	Bindings: CloudflareBindings;
	Variables: ContextVariables;
};
 
const MAX_MATCHES = 10;
 
const localitiesRoutes = new Hono<Env>();
 
localitiesRoutes.get("/lookup", async (c) => {
	try {
		const name = c.req.query("name")?.trim();
		const pincode = c.req.query("pincode")?.trim();
		const slug = c.req.query("slug")?.trim();
 
		// At least one query mode must be specified — otherwise it's
		// ambiguous whether the caller wants "all localities" (which is
		// what GET /api/marketplace/localities should return) or made a
		// mistake. We bias toward "tell the caller their request is
		// incomplete" rather than dump everything.
		if (!name && !pincode && !slug) {
			return c.json(
				{
					success: false,
					error:
						"Must provide one of `name`, `pincode`, or `slug` query parameter",
				},
				400,
			);
		}
 
		const db = c.get("db");
 
		// Single fetch covers all three lookup modes. Localities is small
		// (currently 33 rows; ~92 after expansion) so a full scan is the
		// right tradeoff vs. building a per-mode SQL query — the in-memory
		// resolvers have to scan in any case for fuzzy matching.
		const rows = await db
			.select({
				id: localities.id,
				name: localities.name,
				slug: localities.slug,
				zoneId: localities.zoneId,
				pinCodes: localities.pinCodes,
			})
			.from(localities);
 
		const lookupRows: LocalityLookupRow[] = rows.map((r) => ({
			id: r.id,
			name: r.name,
			slug: r.slug,
			zoneId: r.zoneId,
			pinCodes: r.pinCodes,
		}));
 
		let matches: LocalityMatch[];
		if (pincode) {
			matches = resolveByPincode(lookupRows, pincode);
		} else if (slug) {
			matches = resolveBySlug(lookupRows, slug);
		} else {
			// name-mode (already verified at least one was provided)
			matches = resolveByName(lookupRows, name as string);
		}
 
		matches = matches.slice(0, MAX_MATCHES);
 
		// Hydrate zone+city display names so the chatgpt-app can show
		// "Madhapur, West Hyderabad" without a second round-trip. Only
		// fetched when there's something to enrich.
		let enriched: Array<
			LocalityMatch & {
				zoneName: string | null;
				cityId: string | null;
				cityName: string | null;
			}
		> = matches.map((m) => ({
			...m,
			zoneName: null,
			cityId: null,
			cityName: null,
		}));
 
		if (matches.length > 0) {
			// Fetch only the zones referenced by these matches, then only
			// the cities referenced by those zones — at most one round-trip
			// each. Earlier we scanned the full zones+cities tables; on a
			// 1-row pincode hit that meant ~30 unused rows pulled from D1.
			const matchedZoneIds = [...new Set(matches.map((m) => m.zoneId))];
			const matchedZones = await db
				.select()
				.from(zones)
				.where(inArray(zones.id, matchedZoneIds));
			const zoneMap = new Map(matchedZones.map((z) => [z.id, z]));
 
			const matchedCityIds = [
				...new Set(matchedZones.map((z) => z.cityId)),
			];
			const matchedCities =
				matchedCityIds.length > 0
					? await db
							.select()
							.from(cities)
							.where(inArray(cities.id, matchedCityIds))
					: [];
			const cityMap = new Map(matchedCities.map((ct) => [ct.id, ct]));
 
			enriched = matches.map((m) => {
				const z = zoneMap.get(m.zoneId);
				const city = z ? cityMap.get(z.cityId) : undefined;
				return {
					...m,
					zoneName: z?.name ?? null,
					cityId: z?.cityId ?? null,
					cityName: city?.name ?? null,
				};
			});
		}
 
		return success(c, {
			query: { name, pincode, slug },
			matches: enriched,
			count: enriched.length,
		});
	} catch (err) {
		return handleError(c, err);
	}
});
 
export default localitiesRoutes;