From 9dcce76f0178b370e11329ffe72ad306ad592bac Mon Sep 17 00:00:00 2001 From: Bert Hausmans Date: Thu, 21 May 2026 00:23:10 +0200 Subject: [PATCH] feat(marketplace): list shared roots with filters + sort + pagination --- packages/backend/src/routes/marketplace.ts | 17 ++++ .../backend/src/services/marketplace.test.ts | 69 ++++++++++++++ packages/backend/src/services/marketplace.ts | 94 +++++++++++++++++++ 3 files changed, 180 insertions(+) create mode 100644 packages/backend/src/routes/marketplace.ts create mode 100644 packages/backend/src/services/marketplace.test.ts create mode 100644 packages/backend/src/services/marketplace.ts diff --git a/packages/backend/src/routes/marketplace.ts b/packages/backend/src/routes/marketplace.ts new file mode 100644 index 0000000..7c2bc01 --- /dev/null +++ b/packages/backend/src/routes/marketplace.ts @@ -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; +} diff --git a/packages/backend/src/services/marketplace.test.ts b/packages/backend/src/services/marketplace.test.ts new file mode 100644 index 0000000..bb300b3 --- /dev/null +++ b/packages/backend/src/services/marketplace.test.ts @@ -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; +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'); + }); +}); diff --git a/packages/backend/src/services/marketplace.ts b/packages/backend/src/services/marketplace.ts new file mode 100644 index 0000000..a2f77cf --- /dev/null +++ b/packages/backend/src/services/marketplace.ts @@ -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 { + 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`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`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 }; +}