feat(26-01): implement discovery service with cursor pagination
- getPopularSetups: public setups ordered by item count desc, composite cursor pagination - getRecentGlobalItems: global items ordered by createdAt desc, ISO timestamp cursor - getTrendingCategories: category counts ordered desc, null categories excluded, simple limit - Shared CursorPage<T> response shape with hasMore and nextCursor fields
This commit is contained in:
130
src/server/services/discovery.service.ts
Normal file
130
src/server/services/discovery.service.ts
Normal file
@@ -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<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 (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<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.isPublic, true))
|
||||||
|
.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>> {
|
||||||
|
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<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,
|
||||||
|
}));
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user