feat(marketplace): list shared roots with filters + sort + pagination
This commit is contained in:
17
packages/backend/src/routes/marketplace.ts
Normal file
17
packages/backend/src/routes/marketplace.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { Router } from 'express';
|
||||
import { marketplaceQuerySchema } from '@flashcard/shared';
|
||||
import type { Db } from '../db/client.js';
|
||||
import { listMarketplaceLessons } from '../services/marketplace.js';
|
||||
|
||||
export function marketplaceRouter(db: Db): Router {
|
||||
const r = Router();
|
||||
|
||||
r.get('/lessons', async (req, res, next) => {
|
||||
try {
|
||||
const params = marketplaceQuerySchema.parse(req.query);
|
||||
res.json(await listMarketplaceLessons(db, req.user!.id, params));
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
return r;
|
||||
}
|
||||
69
packages/backend/src/services/marketplace.test.ts
Normal file
69
packages/backend/src/services/marketplace.test.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { makeTestDb, createUserDirect, createLessonOwnedBy } from '../tests/dbHelper.js';
|
||||
import { createCard } from '../services/cards.js';
|
||||
import { listMarketplaceLessons } from './marketplace.js';
|
||||
|
||||
let env: ReturnType<typeof makeTestDb>;
|
||||
beforeEach(() => { env = makeTestDb(); });
|
||||
|
||||
describe('marketplace', () => {
|
||||
it('lists shared roots from other users', async () => {
|
||||
const o = await createUserDirect(env.db, { email: 'o@example.com', displayName: 'Owner' });
|
||||
const u = await createUserDirect(env.db, { email: 'u@example.com' });
|
||||
await createLessonOwnedBy(env.db, o.id, { name: 'A', visibility: 'shared' });
|
||||
await createLessonOwnedBy(env.db, o.id, { name: 'B', visibility: 'private' });
|
||||
const r = await listMarketplaceLessons(env.db, u.id, {});
|
||||
expect(r.rows).toHaveLength(1);
|
||||
expect(r.rows[0]!.name).toBe('A');
|
||||
expect(r.rows[0]!.ownerDisplayName).toBe('Owner');
|
||||
});
|
||||
|
||||
it('excludes own lessons', async () => {
|
||||
const u = await createUserDirect(env.db, { email: 'u@example.com' });
|
||||
await createLessonOwnedBy(env.db, u.id, { name: 'Mine', visibility: 'shared' });
|
||||
const r = await listMarketplaceLessons(env.db, u.id, {});
|
||||
expect(r.rows).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('excludes children of shared roots', async () => {
|
||||
const o = await createUserDirect(env.db, { email: 'o@example.com' });
|
||||
const u = await createUserDirect(env.db, { email: 'u@example.com' });
|
||||
const root = await createLessonOwnedBy(env.db, o.id, { name: 'R', visibility: 'shared' });
|
||||
await createLessonOwnedBy(env.db, o.id, { name: 'C', parentId: root.id, visibility: 'shared' });
|
||||
const r = await listMarketplaceLessons(env.db, u.id, {});
|
||||
expect(r.rows.find((x) => x.name === 'C')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('counts cards recursively over subtree', async () => {
|
||||
const o = await createUserDirect(env.db, { email: 'o@example.com' });
|
||||
const u = await createUserDirect(env.db, { email: 'u@example.com' });
|
||||
const root = await createLessonOwnedBy(env.db, o.id, { name: 'R', visibility: 'shared' });
|
||||
const child = await createLessonOwnedBy(env.db, o.id, { name: 'C', parentId: root.id });
|
||||
await createCard(env.db, o.id, root.id, { question: 'q1', answer: 'a' });
|
||||
await createCard(env.db, o.id, child.id, { question: 'q2', answer: 'a' });
|
||||
const r = await listMarketplaceLessons(env.db, u.id, {});
|
||||
expect(r.rows[0]!.totalCards).toBe(2);
|
||||
});
|
||||
|
||||
it('curated filter and sorting', async () => {
|
||||
const o = await createUserDirect(env.db, { email: 'o@example.com' });
|
||||
const u = await createUserDirect(env.db, { email: 'u@example.com' });
|
||||
await createLessonOwnedBy(env.db, o.id, { name: 'Plain', visibility: 'shared' });
|
||||
await createLessonOwnedBy(env.db, o.id, { name: 'Star', visibility: 'shared', isCurated: true });
|
||||
const r = await listMarketplaceLessons(env.db, u.id, {});
|
||||
expect(r.rows[0]!.name).toBe('Star');
|
||||
const curated = await listMarketplaceLessons(env.db, u.id, { curated: 'true' });
|
||||
expect(curated.rows).toHaveLength(1);
|
||||
expect(curated.rows[0]!.name).toBe('Star');
|
||||
});
|
||||
|
||||
it('q filter is case-insensitive on name', async () => {
|
||||
const o = await createUserDirect(env.db, { email: 'o@example.com' });
|
||||
const u = await createUserDirect(env.db, { email: 'u@example.com' });
|
||||
await createLessonOwnedBy(env.db, o.id, { name: 'Spaans', visibility: 'shared' });
|
||||
await createLessonOwnedBy(env.db, o.id, { name: 'Frans', visibility: 'shared' });
|
||||
const r = await listMarketplaceLessons(env.db, u.id, { q: 'span' });
|
||||
expect(r.rows).toHaveLength(1);
|
||||
expect(r.rows[0]!.name).toBe('Spaans');
|
||||
});
|
||||
});
|
||||
94
packages/backend/src/services/marketplace.ts
Normal file
94
packages/backend/src/services/marketplace.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { eq, inArray, sql } from 'drizzle-orm';
|
||||
import type { Db } from '../db/client.js';
|
||||
import { cards, lessons, lessonSubscriptions, users } from '../db/schema.js';
|
||||
import type { MarketplaceLesson, MarketplaceQuery } from '@flashcard/shared';
|
||||
|
||||
export interface MarketplaceResult { rows: MarketplaceLesson[]; total: number; }
|
||||
|
||||
export async function listMarketplaceLessons(
|
||||
db: Db, userId: number, params: MarketplaceQuery
|
||||
): Promise<MarketplaceResult> {
|
||||
const allShared = db.select().from(lessons).where(eq(lessons.visibility, 'shared')).all();
|
||||
const sharedIds = new Set(allShared.map((l) => l.id));
|
||||
|
||||
// A lesson is a marketplace-root if its parent is NOT also a shared lesson.
|
||||
let candidates = allShared.filter((l) =>
|
||||
l.ownerId !== userId
|
||||
&& (l.parentId === null || !sharedIds.has(l.parentId))
|
||||
);
|
||||
|
||||
if (params.curated === 'true') {
|
||||
candidates = candidates.filter((l) => l.isCurated === true);
|
||||
}
|
||||
if (params.q && params.q.trim() !== '') {
|
||||
const q = params.q.trim().toLowerCase();
|
||||
const matches = (haystack: string): boolean => {
|
||||
const h = haystack.toLowerCase();
|
||||
if (h.includes(q)) return true;
|
||||
// Subsequence match: chars of q appear in order in haystack
|
||||
let i = 0;
|
||||
for (let j = 0; j < h.length && i < q.length; j++) {
|
||||
if (h[j] === q[i]) i++;
|
||||
}
|
||||
return i === q.length;
|
||||
};
|
||||
candidates = candidates.filter((l) =>
|
||||
matches(l.name) || matches(l.description ?? '')
|
||||
);
|
||||
}
|
||||
|
||||
// Sort: curated first, then subscribersCount desc, then createdAt desc.
|
||||
const subsCount = db.select({ lessonId: lessonSubscriptions.lessonId, c: sql<number>`count(*)`.as('c') })
|
||||
.from(lessonSubscriptions).groupBy(lessonSubscriptions.lessonId).all();
|
||||
const subsByLesson = new Map(subsCount.map((s) => [s.lessonId, Number(s.c)]));
|
||||
|
||||
candidates.sort((a, b) => {
|
||||
if (a.isCurated !== b.isCurated) return a.isCurated ? -1 : 1;
|
||||
const sa = subsByLesson.get(a.id) ?? 0;
|
||||
const sb = subsByLesson.get(b.id) ?? 0;
|
||||
if (sa !== sb) return sb - sa;
|
||||
return b.createdAt - a.createdAt;
|
||||
});
|
||||
|
||||
const total = candidates.length;
|
||||
const offset = params.offset ?? 0;
|
||||
const limit = params.limit ?? 50;
|
||||
const page = candidates.slice(offset, offset + limit);
|
||||
|
||||
const ownerIds = Array.from(new Set(page.map((l) => l.ownerId).filter((id): id is number => id !== null && id !== undefined)));
|
||||
const ownersRows = ownerIds.length === 0 ? [] :
|
||||
db.select({ id: users.id, displayName: users.displayName })
|
||||
.from(users).where(inArray(users.id, ownerIds)).all();
|
||||
const ownerMap = new Map(ownersRows.map((u) => [u.id, u.displayName]));
|
||||
|
||||
const rows: MarketplaceLesson[] = [];
|
||||
for (const l of page) {
|
||||
// BFS subtree to count cards
|
||||
const descendantIds: number[] = [l.id];
|
||||
const queue = [l.id];
|
||||
while (queue.length) {
|
||||
const cur = queue.shift()!;
|
||||
const children = db.select({ id: lessons.id })
|
||||
.from(lessons).where(eq(lessons.parentId, cur)).all();
|
||||
for (const c of children) {
|
||||
descendantIds.push(c.id);
|
||||
queue.push(c.id);
|
||||
}
|
||||
}
|
||||
const cardCountRow = db.select({ c: sql<number>`count(*)`.as('c') })
|
||||
.from(cards).where(inArray(cards.lessonId, descendantIds)).get();
|
||||
rows.push({
|
||||
id: l.id,
|
||||
name: l.name,
|
||||
description: l.description ?? null,
|
||||
ownerDisplayName: ownerMap.get(l.ownerId ?? -1) ?? '—',
|
||||
totalCards: Number(cardCountRow?.c ?? 0),
|
||||
subscribersCount: subsByLesson.get(l.id) ?? 0,
|
||||
isCurated: l.isCurated,
|
||||
isFork: l.sourceLessonId !== null && l.sourceLessonId !== undefined,
|
||||
createdAt: l.createdAt,
|
||||
});
|
||||
}
|
||||
|
||||
return { rows, total };
|
||||
}
|
||||
Reference in New Issue
Block a user