189 lines
5.1 KiB
TypeScript
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;
|
|
}
|