feat(catalog): full-catalog search on vehicle page (leaf categories + OEM parts)

The vehicle page search previously only filtered category names at the
currently rendered level. Add a server-side cross-tree search over what's
already drilled into the DB.

New GET /categories/search/:vehicleId?q= returns two sections:
- categories: name-matched leaves UNION the leaf categories that contain a
  matching part (with hit count). "fren balatası" matches no leaf by name —
  the pads are parts under leaves like "Disk freni" — so the union surfaces
  the right leaves.
- parts: parts matching every token on name, or the raw query on oem_code,
  with OEM + leaf + breadcrumb.

Pure DB read (no upstream drill); a treeIncomplete hint is returned when the
vehicle's tree looks barely drilled. Frontend adds a debounced search box on
the vehicle page that hides the normal browse while active.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-27 13:30:21 +03:00
parent 9753d00015
commit 8718b08415
4 changed files with 446 additions and 17 deletions

View File

@@ -1,4 +1,4 @@
import { Controller, Get, Param } from "@nestjs/common";
import { Controller, Get, Param, Query } from "@nestjs/common";
import { CategoriesService } from "./categories.service";
@Controller("categories")
@@ -10,6 +10,11 @@ export class CategoriesController {
return this.categoriesService.getCategoryTree(vehicleId);
}
@Get("search/:vehicleId")
async searchCatalog(@Param("vehicleId") vehicleId: string, @Query("q") q: string) {
return this.categoriesService.searchCatalog(vehicleId, q ?? "");
}
@Get(":id/children")
async getChildren(@Param("id") id: string) {
return this.categoriesService.getChildren(id);

View File

@@ -1,5 +1,5 @@
import { Inject, Injectable, Logger, NotFoundException } from "@nestjs/common";
import { eq, inArray, isNull, sql } from "drizzle-orm";
import { and, eq, ilike, inArray, isNull, or, sql } from "drizzle-orm";
import { DATABASE, type Database } from "../database/database.provider";
import { categories, parts, schemaPics, vehicles } from "../database/schema/core";
import { EmexService } from "../integrations/emex/emex.service";
@@ -615,6 +615,172 @@ export class CategoriesService {
return rows.map((r) => ({ id: r.id, name: r.name }));
}
/**
* Build root→parent breadcrumb trails for many categories in one round-trip.
* The per-node trail excludes the node itself, ordered root-first. Used by the
* catalog search so each hit can show where it sits in the tree.
*/
private async buildBreadcrumbs(
ids: string[],
): Promise<Map<string, Array<{ id: string; name: string }>>> {
const map = new Map<string, Array<{ id: string; name: string }>>();
if (ids.length === 0) return map;
const rows = await this.db.execute<{
start_id: string;
id: string;
name: string;
depth: number;
}>(sql`
WITH RECURSIVE anc AS (
SELECT id AS start_id, id, name, parent_id, 0 AS depth
FROM categories
WHERE id IN (${sql.join(
ids.map((i) => sql`${i}`),
sql`, `,
)})
UNION ALL
SELECT a.start_id, c.id, c.name, c.parent_id, a.depth + 1
FROM anc a
JOIN categories c ON c.id = a.parent_id
)
SELECT start_id, id, name, depth FROM anc WHERE depth > 0 ORDER BY start_id, depth DESC
`);
for (const r of rows) {
const trail = map.get(r.start_id) ?? [];
trail.push({ id: r.id, name: r.name });
map.set(r.start_id, trail);
}
return map;
}
/**
* Full-catalog search for a single vehicle, over what has already been drilled
* into the DB. Returns two sections:
* - categories: leaf (and parent) categories whose name matches every token
* - parts: parts whose name matches every token, or whose OEM code contains
* the raw query
* Multi-word queries are AND-ed across tokens; matching is case-insensitive
* (ILIKE) and checks both the Turkish `name` and the original `nameOriginal`.
* Pure DB read — does not trigger any upstream drill (see getChildren for that).
*/
async searchCatalog(vehicleId: string, rawQuery: string) {
const query = (rawQuery ?? "").trim();
if (query.length < 2) return { query, categories: [], parts: [], treeIncomplete: false };
const tokens = query.toLocaleLowerCase("tr").split(/\s+/).filter(Boolean).slice(0, 6);
if (tokens.length === 0) return { query, categories: [], parts: [], treeIncomplete: false };
// ── Section 1: categories (match every token on name OR nameOriginal) ──
const catTokenConds = tokens.map((tok) =>
or(ilike(categories.name, `%${tok}%`), ilike(categories.nameOriginal, `%${tok}%`)),
);
const matchedCats = await this.db
.select({
id: categories.id,
name: categories.name,
source: categories.source,
unavailable: categories.unavailable,
})
.from(categories)
.where(and(eq(categories.vehicleId, vehicleId), ...catTokenConds))
.limit(80);
// ── Section 2: parts (every token on name, OR raw query on OEM code) ──
const partTokenConds = tokens.map((tok) => ilike(parts.name, `%${tok}%`));
const matchedParts = await this.db
.select({
oemCode: parts.oemCode,
name: parts.name,
categoryId: parts.categoryId,
categoryName: categories.name,
unavailable: parts.unavailable,
})
.from(parts)
.innerJoin(categories, eq(parts.categoryId, categories.id))
.where(
and(
eq(parts.vehicleId, vehicleId),
or(and(...partTokenConds), ilike(parts.oemCode, `%${query}%`)),
),
)
.limit(80);
// Section 1 = categories whose NAME matches the leaf categories that CONTAIN
// a matching part. "fren balatası" matches no leaf literally named that, but
// the leaves holding those parts (e.g. "Disk freni") are exactly what the user
// is after — so fold the matched parts' categories in, with a hit count.
const partCatInfo = new Map<string, { name: string; count: number }>();
for (const p of matchedParts) {
const cur = partCatInfo.get(p.categoryId);
if (cur) cur.count += 1;
else partCatInfo.set(p.categoryId, { name: p.categoryName, count: 1 });
}
const namedCats = new Map(matchedCats.map((c) => [c.id, c]));
const allCatIds = [...new Set([...namedCats.keys(), ...partCatInfo.keys()])];
// A category is a "leaf" (a real parts page) when nothing points to it as a
// parent. Un-drilled mid-groups can be mis-flagged as leaves, but clicking one
// just drills it like any browse, so this stays safe.
const parentRows = allCatIds.length
? await this.db
.selectDistinct({ parentId: categories.parentId })
.from(categories)
.where(inArray(categories.parentId, allCatIds))
: [];
const parentSet = new Set(parentRows.map((r) => r.parentId).filter(Boolean) as string[]);
// Breadcrumbs for every category referenced by either section, in one query.
const crumbs = await this.buildBreadcrumbs(allCatIds);
const categoryResults = allCatIds
.map((id) => {
const named = namedCats.get(id);
const viaPart = partCatInfo.get(id);
return {
id,
name: named?.name ?? viaPart?.name ?? "",
source: named?.source,
unavailable: named?.unavailable ?? false,
isLeaf: !parentSet.has(id),
matchingPartCount: viaPart?.count ?? 0,
breadcrumb: crumbs.get(id) ?? [],
};
})
// Categories that actually contain matching parts lead, then other leaves.
.sort(
(a, b) =>
b.matchingPartCount - a.matchingPartCount ||
Number(b.isLeaf) - Number(a.isLeaf) ||
a.name.localeCompare(b.name, "tr"),
)
.slice(0, 50);
const partResults = matchedParts.slice(0, 50).map((p) => ({
oemCode: p.oemCode,
name: p.name,
categoryId: p.categoryId,
categoryName: p.categoryName,
unavailable: p.unavailable,
breadcrumb: crumbs.get(p.categoryId) ?? [],
}));
// Hint the UI when this vehicle's tree looks barely drilled (roots only, no
// parts): search only sees what's in the DB, so results may be sparse.
const [catAgg] = await this.db
.select({ n: sql<number>`count(*)`.mapWith(Number) })
.from(categories)
.where(eq(categories.vehicleId, vehicleId));
const [partAgg] = await this.db
.select({ n: sql<number>`count(*)`.mapWith(Number) })
.from(parts)
.where(eq(parts.vehicleId, vehicleId));
const treeIncomplete = partAgg.n === 0 && catAgg.n <= 20;
return { query, categories: categoryResults, parts: partResults, treeIncomplete };
}
async getCategoryWithParts(categoryId: string) {
const result = await this.getCategoryWithPartsInner(categoryId);
// Attach the full ancestor trail so the client can render a complete,