diff --git a/src/server/services/discovery.service.ts b/src/server/services/discovery.service.ts new file mode 100644 index 0000000..dd0efe8 --- /dev/null +++ b/src/server/services/discovery.service.ts @@ -0,0 +1,130 @@ +import { and, count, desc, eq, isNotNull, lt, sql } from "drizzle-orm"; +import { db as prodDb } from "../../db/index.ts"; +import { globalItems, setups, setupItems, users } from "../../db/schema.ts"; + +type Db = typeof prodDb; + +interface CursorPage { + 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 (isPublic=true) 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`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.isPublic, true)) + .groupBy(setups.id, setups.name, setups.createdAt, users.displayName) + .orderBy( + desc(sql`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> { + const conditions = cursor + ? [lt(globalItems.createdAt, new Date(cursor))] + : []; + + const rows = await db + .select() + .from(globalItems) + .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> { + 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, + })); +}