feat(19-03): add tag filtering to global item search and migrate owner count

- searchGlobalItems now accepts tagNames param with AND intersection logic
- Owner count uses items.globalItemId instead of removed itemGlobalLinks
- Removed linkItemToGlobal and unlinkItemFromGlobal functions
- Route handlers now async with tags query param support
- Rewrote tests to async PGlite pattern, added tag filtering tests

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-05 20:55:36 +02:00
parent 1bdb34d33e
commit ecc6ac689a
4 changed files with 309 additions and 253 deletions

View File

@@ -9,21 +9,26 @@ type Env = { Variables: { db?: any } };
const app = new Hono<Env>();
app.get("/", (c) => {
app.get("/", async (c) => {
const db = c.get("db");
const q = c.req.query("q");
const items = searchGlobalItems(db, q || undefined);
const tagsParam = c.req.query("tags");
const tagNames = tagsParam
? tagsParam
.split(",")
.map((t) => t.trim())
.filter(Boolean)
: undefined;
const items = await searchGlobalItems(db, q || undefined, tagNames);
return c.json(items);
});
app.get("/:id", (c) => {
app.get("/:id", async (c) => {
const db = c.get("db");
const id = parseId(c.req.param("id"));
if (!id) return c.json({ error: "Invalid global item ID" }, 400);
const item = getGlobalItemWithOwnerCount(db, id);
const item = await getGlobalItemWithOwnerCount(db, id);
if (!item) return c.json({ error: "Global item not found" }, 404);
return c.json(item);
});

View File

@@ -1,32 +1,63 @@
import { count, eq, like, or, sql } from "drizzle-orm";
import { and, count, eq, ilike, or, sql } from "drizzle-orm";
import type { SQL } from "drizzle-orm";
import { db as prodDb } from "../../db/index.ts";
import { globalItems, itemGlobalLinks } from "../../db/schema.ts";
import { globalItemTags, globalItems, items, tags } from "../../db/schema.ts";
type Db = typeof prodDb;
/**
* Search global items by brand or model. LIKE is case-insensitive for ASCII.
* Search global items by brand or model and/or tag names.
* Text search uses ILIKE for case-insensitive matching (PostgreSQL).
* Tag filtering uses AND logic -- items must have ALL specified tags.
* Escapes % and _ wildcard characters in user input.
*/
export async function searchGlobalItems(db: Db = prodDb, query?: string) {
if (!query) {
return db.select().from(globalItems);
export async function searchGlobalItems(
db: Db = prodDb,
query?: string,
tagNames?: string[],
) {
const conditions: SQL[] = [];
if (query) {
const escaped = query.replace(/%/g, "\\%").replace(/_/g, "\\_");
const pattern = `%${escaped}%`;
conditions.push(
or(
ilike(globalItems.brand, pattern),
ilike(globalItems.model, pattern),
)!,
);
}
// Escape SQL LIKE wildcards
const escaped = query.replace(/%/g, "\\%").replace(/_/g, "\\_");
const pattern = `%${escaped}%`;
if (tagNames && tagNames.length > 0) {
conditions.push(
sql`${globalItems.id} IN (
SELECT ${globalItemTags.globalItemId}
FROM ${globalItemTags}
JOIN ${tags} ON ${tags.id} = ${globalItemTags.tagId}
WHERE ${tags.name} IN (${sql.join(
tagNames.map((t) => sql`${t}`),
sql`, `,
)})
GROUP BY ${globalItemTags.globalItemId}
HAVING COUNT(DISTINCT ${tags.name}) = ${tagNames.length}
)`,
);
}
if (conditions.length === 0) {
return db.select().from(globalItems);
}
return db
.select()
.from(globalItems)
.where(
or(like(globalItems.brand, pattern), like(globalItems.model, pattern)),
);
.where(and(...conditions));
}
/**
* Get a single global item by ID with the count of user items linked to it.
* Get a single global item by ID with the count of user items referencing it
* via items.globalItemId.
*/
export async function getGlobalItemWithOwnerCount(
db: Db = prodDb,
@@ -41,35 +72,8 @@ export async function getGlobalItemWithOwnerCount(
const [result] = await db
.select({ ownerCount: count() })
.from(itemGlobalLinks)
.where(eq(itemGlobalLinks.globalItemId, id));
.from(items)
.where(eq(items.globalItemId, id));
return { ...item, ownerCount: result?.ownerCount ?? 0 };
}
/**
* Link a user's item to a global item. Throws on duplicate (unique constraint on itemId).
*/
export async function linkItemToGlobal(
db: Db = prodDb,
itemId: number,
globalItemId: number,
) {
const [row] = await db
.insert(itemGlobalLinks)
.values({ itemId, globalItemId })
.returning();
return row;
}
/**
* Remove the link between a user's item and any global item.
*/
export async function unlinkItemFromGlobal(db: Db = prodDb, itemId: number) {
const result = await db
.delete(itemGlobalLinks)
.where(eq(itemGlobalLinks.itemId, itemId))
.returning();
return result.length;
}