All files / routes/marketplace search.routes.ts

96.25% Statements 77/80
86% Branches 43/50
87.5% Functions 7/8
95.94% Lines 71/74

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 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243                                  28x 11x     5x 11x 2x 11x                   70x 70x 70x 70x 70x           198x                   70x 70x 70x 70x   70x   70x 69x   69x 69x 2x                     67x     67x   67x 7x 7x 3x 1x               2x       66x       69x     69x 69x 69x     69x     69x 69x     69x 69x 69x 66x     66x   66x   66x         66x 66x 66x 66x 66x         66x                         66x       66x 66x   66x       37x 37x           36x 36x           36x           36x           36x 37x     37x 7x 7x   36x           36x                       28x       64x                           2x              
// Search API endpoint for marketplace full-text search
import { getRoomCategorySlug } from "@interioring/utils/constants/room-categories";
import { Hono } from "hono";
import { SearchService } from "../../lib/search/search-service";
import { handleError, success } from "../../lib/response";
import type { EntityType, SearchResult } from "../../lib/search/types";
import type { ContextVariables } from "../../middleware";
import { buildDictionary, enrichSearchResults } from "./search/enrichment";
 
// Re-derive `/rooms/<category>/<slug>` for room results from the live slug map,
// overriding the indexed `displayUrl`. The R2 search index is rebuilt on an
// hourly cron, so any change to ROOM_CATEGORY_SLUGS leaves stale URLs in the
// index until that cron tick lands and propagates. The dropdown path reads
// `displayUrl` straight from the index (no enrichment), so without this guard
// a stale index returns single-segment URLs that 404 on `/rooms/[category]`.
// See #570.
function normalizeRoomUrls(results: SearchResult[]): SearchResult[] {
	return results.map((r) => {
		if (r.type !== "room") return r;
		// SearchService always emits `meta` for results; treat missing fields
		// inside it as empty strings rather than guarding the whole object.
		const slug = (r.meta.slug as string | undefined) ?? "";
		if (!slug) return r;
		const roomType = (r.meta.roomType as string | undefined) ?? "";
		return { ...r, url: `/rooms/${getRoomCategorySlug(roomType)}/${slug}` };
	});
}
 
type Env = {
	Bindings: CloudflareBindings;
	Variables: ContextVariables;
};
 
 
const VALID_TYPES = new Set<EntityType>(["pro", "project", "room", "blog"]);
const MAX_LIMIT = 50;
const DEFAULT_LIMIT = 10;
const MIN_QUERY_LENGTH = 2;
const MAX_FACET_IDS = 20;
 
// Parse a comma-separated id list, trimming, dropping empties, and enforcing
// the same per-filter cap the pros DAL uses so a malicious caller can't blow
// up the in-memory facet check.
function parseIdList(raw: string | undefined): string[] | undefined {
	Eif (!raw) return undefined;
	const ids = raw
		.split(",")
		.map((id) => id.trim())
		.filter(Boolean)
		.slice(0, MAX_FACET_IDS);
	return ids.length > 0 ? ids : undefined;
}
 
// Module-scope warm cache for search service (persists across requests in same isolate)
let cachedService: SearchService | null = null;
let cachedVersion: string | null = null;
let lastVersionCheck = 0;
const VERSION_CHECK_INTERVAL = 60_000; // 60 seconds
 
const search = new Hono<Env>();
 
search.get("/", async (c) => {
	try {
		// 1. Validate query params
		const q = c.req.query("q")?.trim();
		if (!q || q.length < MIN_QUERY_LENGTH) {
			return c.json(
				{
					success: false,
					error: `Query parameter "q" is required and must be at least ${MIN_QUERY_LENGTH} characters`,
				},
				400,
			);
		}
 
		// Sanitize: strip control characters, limit length
		// biome-ignore lint/suspicious/noControlCharactersInRegex: intentionally stripping control characters from user input
		const sanitizedQuery = q.replace(/[\x00-\x1f]/g, "").substring(0, 200);
 
		// Parse types (comma-separated, validate each)
		const typesParam = c.req.query("types");
		let types: EntityType[] | undefined;
		if (typesParam) {
			const parsed = typesParam.split(",").map((t) => t.trim()) as EntityType[];
			const invalid = parsed.filter((t) => !VALID_TYPES.has(t));
			if (invalid.length > 0) {
				return c.json(
					{
						success: false,
						error: `Invalid type(s): ${invalid.join(", ")}. Valid types: pro, project, room, blog`,
					},
					400,
				);
			}
			types = parsed;
		}
 
		// Parse limit and page
		const limit = Math.min(
			Math.max(1, Number(c.req.query("limit")) || DEFAULT_LIMIT),
			MAX_LIMIT,
		);
		const page = Math.max(1, Number(c.req.query("page")) || 1);
 
		// Parse facet filters (applied post-search against indexed facet fields)
		const cityIds = parseIdList(c.req.query("cityIds"));
		const localityId = c.req.query("localityId")?.trim() || undefined;
		const serviceCategoryIds = parseIdList(
			c.req.query("serviceCategoryIds"),
		);
		const materialTagIds = parseIdList(c.req.query("materialTagIds"));
 
		// Parse diversity mode: "strict" = max-1-per-pro (for autocomplete dropdowns)
		const diversityParam = c.req.query("diversity");
		const diversity = diversityParam === "strict" ? "strict" as const : "default" as const;
 
		// 2. Load or reuse cached SearchService (throttle KV version checks to once per 60s)
		const now = Date.now();
		let currentVersion = cachedVersion;
		if (!cachedService || now - lastVersionCheck > VERSION_CHECK_INTERVAL) {
			currentVersion = c.env.KV_CACHE
				? await c.env.KV_CACHE.get("search-index-version")
				: null;
			lastVersionCheck = now;
		}
		Eif (!cachedService || cachedVersion !== currentVersion) {
			// Build dictionary from taxonomy tables for typo correction
			const dictionary = await buildDictionary(c.get("dal"));
			// Read env-tunable weights (SEARCH_RELEVANCE_WEIGHT / SEARCH_QUALITY_WEIGHT).
			// Defaults: 0.7 / 0.3 (70% text relevance, 30% quality score).
			// Cast through unknown because CloudflareBindings is a typed interface and these
			// vars may not be declared until wrangler.jsonc is updated (Lane B).
			const env = c.env as unknown as Record<string, unknown>;
			const relevanceWeight = Number(env.SEARCH_RELEVANCE_WEIGHT) || 0.7;
			const qualityWeight = Number(env.SEARCH_QUALITY_WEIGHT) || 0.3;
			cachedService = await SearchService.fromR2(c.env.R2, dictionary, relevanceWeight, qualityWeight);
			cachedVersion = currentVersion;
		}
 
		// 3. Run search (facet filters applied inside SearchService against
		// indexed facet fields; result counts reflect post-filter totals).
		const searchResponse = cachedService.search({
			q: sanitizedQuery,
			types,
			limit,
			page,
			cityIds,
			localityId,
			serviceCategoryIds,
			materialTagIds,
			diversity,
		});
 
		// 4. Enrich results with entity data (only for full SSR page, not autocomplete dropdown)
		const enrich = c.req.query("enrich");
 
		let enrichedResults: SearchResult[];
		let richData: Record<string, unknown> | undefined;
		let counts = searchResponse.counts;
		let total = searchResponse.total;
 
		if (enrich === "full") {
			// Full enrichment rebuilds every room `url` from DB data
			// (`enrichment.ts` line 197) — already against the live slug map,
			// so `normalizeRoomUrls` would just duplicate that work.
			const dal = c.get("dal");
			const enrichment = await enrichSearchResults(
				searchResponse.results,
				dal,
				c.get("db"),
				{ includeRichData: true },
			);
			enrichedResults = enrichment.results;
			richData = enrichment.richData;
 
			// Drop results the DB no longer resolves (stale R2 index pointing at
			// deleted blogs/pros/projects/rooms). Without this filter the SSR
			// renderer would silently emit `null` cards while the type-tab badge
			// kept showing the index count — bug #569.
			const richKeyByType = {
				pro: "pros",
				project: "projects",
				room: "rooms",
				blog: "blogs",
			} as const;
			const dropped: Record<EntityType, number> = {
				pro: 0,
				project: 0,
				room: 0,
				blog: 0,
			};
			enrichedResults = enrichedResults.filter((r) => {
				const bucket = richData?.[richKeyByType[r.type]] as
					| Record<string, unknown>
					| undefined;
				if (bucket && bucket[r.id] !== undefined) return true;
				dropped[r.type]++;
				return false;
			});
			counts = {
				pro: Math.max(0, counts.pro - dropped.pro),
				project: Math.max(0, counts.project - dropped.project),
				room: Math.max(0, counts.room - dropped.room),
				blog: Math.max(0, counts.blog - dropped.blog),
			};
			total = Math.max(
				0,
				total -
					dropped.pro -
					dropped.project -
					dropped.room -
					dropped.blog,
			);
		} else {
			// Autocomplete path: the indexed `displayUrl` is the only URL the
			// client gets. Rebuild room URLs from the live slug map so a stale
			// R2 index can't ship single-segment `/rooms/<slug>` 404s. See #570.
			enrichedResults = normalizeRoomUrls(searchResponse.results);
		}
 
		// 5. Return response
		return success(c, {
			results: enrichedResults,
			query: searchResponse.query,
			corrected: searchResponse.corrected,
			total,
			counts,
			...(richData ? { richData } : {}),
			meta: {
				page,
				limit,
				totalPages: Math.ceil(total / limit),
			},
		});
	} catch (err) {
		return handleError(c, err);
	}
});
 
// Search index rebuild is available at /api/admin/search/rebuild (requires platform admin auth)
 
export default search;