feat(categories): add schema image thumbnails to grid and tree views

Enrich category children with batch-fetched schema images and leaf detection
on the API side. Display thumbnails in grid cards with shimmer loading
placeholders and background prefetching. Add TanStack Query caching for
category children. Fix insert race condition with onConflictDoNothing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Sase Dev
2026-02-12 20:47:50 +00:00
parent a8eb8f4bdc
commit 7b024df4d5
4 changed files with 284 additions and 88 deletions

View File

@@ -1,5 +1,5 @@
import { Inject, Injectable, Logger, NotFoundException } from "@nestjs/common";
import { eq } from "drizzle-orm";
import { eq, inArray, sql } from "drizzle-orm";
import { DATABASE, Database } from "../database/database.provider";
import { categories, vehicles, schemaPics, parts } from "../database/schema/core";
import { RedisService } from "../redis/redis.service";
@@ -194,7 +194,7 @@ export class CategoriesService {
.from(categories)
.where(eq(categories.parentId, categoryId));
if (children.length > 0) return children;
if (children.length > 0) return this.enrichWithSchemaImages(children);
// Fetch from PL24
const [category] = await this.db
@@ -251,13 +251,25 @@ export class CategoriesService {
source: "pl24" as const,
}));
children = await this.db.insert(categories).values(insertData).returning();
children = await this.db
.insert(categories)
.values(insertData)
.onConflictDoNothing()
.returning();
// If onConflict skipped some, re-fetch from DB
if (children.length < insertData.length) {
children = await this.db
.select()
.from(categories)
.where(eq(categories.parentId, categoryId));
}
// Invalidate tree cache since new subgroups were added
await this.redis.del(`cat:tree:${category.vehicleId}`);
}
return children;
return children.length > 0 ? this.enrichWithSchemaImages(children) : children;
}
/**
@@ -288,7 +300,7 @@ export class CategoriesService {
parts: [],
schemaPics: [],
hotspots: [],
children,
children: await this.enrichWithSchemaImages(children),
};
}
@@ -528,6 +540,42 @@ export class CategoriesService {
return { ...category, schemaPics: pics };
}
private async enrichWithSchemaImages(cats: (typeof categories.$inferSelect)[]) {
const catIds = cats.map((c) => c.id);
if (catIds.length === 0) return cats;
// Batch fetch schema images
const pics = await this.db
.select({ categoryId: schemaPics.categoryId, imageUrl: schemaPics.imageUrl })
.from(schemaPics)
.where(inArray(schemaPics.categoryId, catIds));
const picMap = new Map(pics.map((p) => [p.categoryId, p.imageUrl]));
// Batch check which categories have sub-children in DB
const childCounts = await this.db
.select({
parentId: categories.parentId,
count: sql<number>`count(*)::int`,
})
.from(categories)
.where(inArray(categories.parentId, catIds))
.groupBy(categories.parentId);
const childCountMap = new Map(childCounts.map((r) => [r.parentId, r.count]));
return cats.map((c) => {
const dbChildCount = childCountMap.get(c.id) || 0;
// Leaf if: has BOM linkPath, OR has no linkPath and no DB children
const isLeaf = c.linkPath?.includes("/bom/") || (!c.linkPath && dbChildCount === 0);
return {
...c,
schemaImageUrl: picMap.get(c.id) || null,
children: isLeaf ? [] : undefined,
};
});
}
private getImageDimensions(buf: Buffer, ext: string): { width: number; height: number } {
try {
if (ext === 'gif' && buf.length >= 10) {