Files
GearBox/src/server/services/discovery.service.ts

189 lines
5.1 KiB
TypeScript

import { and, count, desc, eq, inArray, isNotNull, lt, sql } from "drizzle-orm";
import { db as prodDb } from "../../db/index.ts";
import {
globalItems,
globalItemTags,
items,
manufacturers,
setupItems,
setups,
tags,
users,
} from "../../db/schema.ts";
type Db = typeof prodDb;
interface CursorPage<T> {
items: T[];
nextCursor: string | null;
hasMore: boolean;
}
/**
* Get popular public setups ordered by item count descending.
* Cursor format: "{itemCount}_{id}" for stable composite pagination.
* Only public setups (visibility='public') are returned.
*/
export async function getPopularSetups(
db: Db = prodDb,
limit = 6,
cursor?: string,
): Promise<
CursorPage<{
id: number;
name: string;
createdAt: Date;
itemCount: number;
creatorName: string | null;
}>
> {
// Fetch more rows when cursor provided to ensure we can filter and still fill the page
const fetchLimit = cursor ? limit * 2 + limit + 1 : limit + 1;
const rows = await db
.select({
id: setups.id,
name: setups.name,
createdAt: setups.createdAt,
itemCount: sql<number>`CAST(COUNT(${setupItems.id}) AS INT)`,
creatorName: users.displayName,
})
.from(setups)
.leftJoin(setupItems, eq(setupItems.setupId, setups.id))
.leftJoin(users, eq(users.id, setups.userId))
.where(eq(setups.visibility, "public"))
.groupBy(setups.id, setups.name, setups.createdAt, users.displayName)
.orderBy(desc(sql<number>`COUNT(${setupItems.id})`), desc(setups.id))
.limit(fetchLimit);
// Apply cursor filter in JS (composite cursor: itemCount_id)
let filtered = rows;
if (cursor) {
const parts = cursor.split("_");
const cursorCount = Number(parts[0]);
const cursorId = Number(parts[1]);
filtered = rows.filter(
(r) =>
r.itemCount < cursorCount ||
(r.itemCount === cursorCount && r.id < cursorId),
);
}
const hasMore = filtered.length > limit;
const items = hasMore ? filtered.slice(0, limit) : filtered;
const nextCursor =
hasMore && items.length > 0
? `${items[items.length - 1].itemCount}_${items[items.length - 1].id}`
: null;
return { items, nextCursor, hasMore };
}
/**
* Get recently added global catalog items ordered by createdAt descending.
* Cursor is an ISO timestamp string — returns items created before that time.
*/
export async function getRecentGlobalItems(
db: Db = prodDb,
limit = 8,
cursor?: string,
): Promise<CursorPage<typeof globalItems.$inferSelect & { brand: string }>> {
const conditions = cursor
? [lt(globalItems.createdAt, new Date(cursor))]
: [];
const rows = await db
.select({ ...globalItems, brand: manufacturers.name })
.from(globalItems)
.innerJoin(manufacturers, eq(globalItems.manufacturerId, manufacturers.id))
.where(conditions.length ? and(...conditions) : undefined)
.orderBy(desc(globalItems.createdAt))
.limit(limit + 1);
const hasMore = rows.length > limit;
const items = hasMore ? rows.slice(0, limit) : rows;
const nextCursor =
hasMore && items.length > 0
? items[items.length - 1].createdAt.toISOString()
: null;
return { items, nextCursor, hasMore };
}
/**
* Get trending categories ordered by global item count descending.
* Excludes items where category IS NULL.
* Simple limit pagination — no cursor (categories are a bounded list).
*/
export async function getTrendingCategories(
db: Db = prodDb,
limit = 12,
): Promise<Array<{ name: string; itemCount: number }>> {
const rows = await db
.select({
name: globalItems.category,
itemCount: count(globalItems.id),
})
.from(globalItems)
.where(isNotNull(globalItems.category))
.groupBy(globalItems.category)
.orderBy(desc(count(globalItems.id)))
.limit(limit);
return rows.map((r) => ({
name: r.name as string,
itemCount: r.itemCount,
}));
}
/**
* Get popular global items filtered by tag names, ordered by owner count descending.
* Owner count = number of user items linked to each global item via globalItemId.
*/
export async function getPopularItemsByTags(
db: Db = prodDb,
tagNames: string[],
limit = 24,
): Promise<
Array<{
id: number;
brand: string | null;
model: string;
category: string | null;
weightGrams: number | null;
priceCents: number | null;
imageUrl: string | null;
description: string | null;
ownerCount: number;
}>
> {
if (tagNames.length === 0) return [];
const rows = await db
.select({
id: globalItems.id,
brand: manufacturers.name,
model: globalItems.model,
category: globalItems.category,
weightGrams: globalItems.weightGrams,
priceCents: globalItems.priceCents,
imageUrl: globalItems.imageUrl,
description: globalItems.description,
ownerCount: sql<number>`CAST(COUNT(DISTINCT ${items.id}) AS INT)`,
})
.from(globalItems)
.innerJoin(manufacturers, eq(globalItems.manufacturerId, manufacturers.id))
.innerJoin(globalItemTags, eq(globalItemTags.globalItemId, globalItems.id))
.innerJoin(tags, eq(tags.id, globalItemTags.tagId))
.leftJoin(items, eq(items.globalItemId, globalItems.id))
.where(inArray(tags.name, tagNames))
.groupBy(globalItems.id)
.orderBy(
desc(sql<number>`COUNT(DISTINCT ${items.id})`),
desc(globalItems.id),
)
.limit(limit);
return rows;
}