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 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 | 1x 1x 1x 8x 8x 8x 8x 8x 1x 7x 7x 7x 7x 2x 1x 12x 6x 1x 5x 2x 3x 6x 6x 6x 5x 5x 5x 5x 5x 5x 5x 5x 5x 5x 6x 1x | // 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";
import { CACHE_TTL, MARKETPLACE } from "../../lib/cache";
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");
const cache = c.get("cache");
// Cache the raw localities table scan (whole-table, ~33 rows). The
// in-memory resolvers run against this cached array so each lookup
// request never hits D1. NEARLY_STATIC TTLs: 60s L1, 24h L2 — safe
// because admin writes go through taxonomy.routes.ts which fires
// invalidateEntity("locality") → cache.delete(MARKETPLACE.localitiesAll).
let rows = await cache.get<
Array<{
id: string;
name: string;
slug: string | null;
zoneId: string;
pinCodes: string[] | null;
}>
>(MARKETPLACE.localitiesAll);
if (!rows) {
// 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.
rows = await db
.select({
id: localities.id,
name: localities.name,
slug: localities.slug,
zoneId: localities.zoneId,
pinCodes: localities.pinCodes,
})
.from(localities);
await cache.put(MARKETPLACE.localitiesAll, rows, {
l1Ttl: CACHE_TTL.NEARLY_STATIC_L1,
l2Ttl: CACHE_TTL.NEARLY_STATIC_L2,
});
}
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;
|