feat(marketplace): list shared roots with filters + sort + pagination

This commit is contained in:
2026-05-21 00:23:10 +02:00
parent 4339728326
commit 9dcce76f01
3 changed files with 180 additions and 0 deletions

View 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;
}

View 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');
});
});

View 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 };
}