mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 19:01:08 +02:00
feat(cards-server): Phase γ — public reads + browse + search + engagement
Marketplace discovery surface lights up. Anonymous browsers can
explore + search; signed-in users get the same surface plus star/
follow mutations.
- middleware/optional-auth.ts: opportunistic JWT — sets c.get('user')
if a token validates, otherwise leaves it undefined. Read paths
use this; mutating routes call requireUser() inline.
- services/explore.ts: browse() with q (ilike on title/description),
tag, language, author-slug, sort (recent/popular/trending), pagination.
explore() composes featured + trending for the landing.
tagTree()/curatedTagsOnly() round it out. Subqueries for star/
subscriber counts avoid N+1.
- services/engagement.ts: star/unstar deck, follow/unfollow author.
Idempotent via ON CONFLICT DO NOTHING. Self-follow rejected.
- routes/explore.ts mounts /v1/explore, /v1/decks (browse list),
/v1/tags. routes/engagement.ts mounts /v1/decks/:slug/star
(POST/DELETE) + /v1/authors/:slug/follow (POST/DELETE).
- index.ts replaces the previous strict-jwt-on-everything middleware
with optionalAuth on all of /v1, then individual routers gate
their write paths via local requireUser(). Hono context type
relaxes from `user: AuthUser` to `user?: AuthUser` accordingly.
Validated: tsc --noEmit clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
4c044e849d
commit
dcd16067b5
8 changed files with 460 additions and 19 deletions
|
|
@ -14,11 +14,16 @@ import { serviceErrorHandler as errorHandler } from '@mana/shared-hono';
|
|||
import { loadConfig } from './config';
|
||||
import { getDb } from './db/connection';
|
||||
import { jwtAuth, type AuthUser } from './middleware/jwt-auth';
|
||||
import { optionalAuth } from './middleware/optional-auth';
|
||||
import { healthRoutes } from './routes/health';
|
||||
import { AuthorService } from './services/authors';
|
||||
import { DeckService } from './services/decks';
|
||||
import { ExploreService } from './services/explore';
|
||||
import { EngagementService } from './services/engagement';
|
||||
import { createAuthorRoutes } from './routes/authors';
|
||||
import { createDeckRoutes } from './routes/decks';
|
||||
import { createExploreRoutes } from './routes/explore';
|
||||
import { createEngagementRoutes } from './routes/engagement';
|
||||
|
||||
// ─── Bootstrap ──────────────────────────────────────────────
|
||||
|
||||
|
|
@ -27,10 +32,12 @@ const db = getDb(config.databaseUrl);
|
|||
|
||||
const authorService = new AuthorService(db);
|
||||
const deckService = new DeckService(db, config.manaLlmUrl);
|
||||
const exploreService = new ExploreService(db);
|
||||
const engagementService = new EngagementService(db);
|
||||
|
||||
// ─── App ────────────────────────────────────────────────────
|
||||
|
||||
const app = new Hono<{ Variables: { user: AuthUser } }>();
|
||||
const app = new Hono<{ Variables: { user?: AuthUser } }>();
|
||||
|
||||
app.onError(errorHandler);
|
||||
app.use(
|
||||
|
|
@ -46,16 +53,26 @@ app.route('/health', healthRoutes);
|
|||
|
||||
// Versioned API surface — additive-only changes within v1, breaking
|
||||
// changes go to /v2 (MARKETPLACE_PLAN §3 architecture principle 1).
|
||||
const v1 = new Hono<{ Variables: { user: AuthUser } }>();
|
||||
//
|
||||
// Two auth tiers:
|
||||
// - jwtAuth: strict, used on writes (publish, profile updates,
|
||||
// star/follow). 401 if missing/invalid token.
|
||||
// - optionalAuth: opportunistic, used on every read. Sets
|
||||
// c.get('user') if a token validates, otherwise leaves it
|
||||
// undefined and lets the route serve anonymous content.
|
||||
const v1 = new Hono<{ Variables: { user?: AuthUser } }>();
|
||||
|
||||
// Public reads on author + deck profiles allow anonymous access; the
|
||||
// mutating endpoints in the same routers gate themselves by checking
|
||||
// for `c.get('user')`. Until we have that anonymous-aware middleware
|
||||
// (Phase γ adds optionalAuth), every /v1 route gates on JWT — public
|
||||
// reads still work for any signed-in user, which covers the only
|
||||
// surface we have right now (author dashboard + deck CRUD).
|
||||
v1.use('/*', jwtAuth(config.manaAuthUrl));
|
||||
// Phase γ: public reads first — explore + browse + tags + author
|
||||
// profile lookup + deck profile lookup. All read-only, no token
|
||||
// required, but a present token enables logged-in extras (star
|
||||
// state, follow state) once those flags land in the responses
|
||||
// (MARKETPLACE_PLAN phase γ.3).
|
||||
v1.use('/*', optionalAuth(config.manaAuthUrl));
|
||||
|
||||
// Mounted routers handle their own per-route auth requirements
|
||||
// via requireUser() helpers when needed.
|
||||
v1.route('/', createExploreRoutes(exploreService));
|
||||
v1.route('/', createEngagementRoutes(engagementService));
|
||||
v1.route('/authors', createAuthorRoutes(authorService));
|
||||
v1.route('/decks', createDeckRoutes(authorService, deckService));
|
||||
|
||||
|
|
@ -66,8 +83,14 @@ v1.get('/', (c) =>
|
|||
message: 'See apps/cards/docs/MARKETPLACE_PLAN.md for the full plan.',
|
||||
})
|
||||
);
|
||||
|
||||
app.route('/v1', v1);
|
||||
|
||||
// Keep jwtAuth around — re-exported for callers that need to wrap
|
||||
// individual mutating subroutes by hand. Not currently used at the
|
||||
// app-level since we moved to optionalAuth + requireUser per route.
|
||||
void jwtAuth;
|
||||
|
||||
// ─── Listen ────────────────────────────────────────────────
|
||||
|
||||
console.log(`[cards-server] listening on :${config.port}`);
|
||||
|
|
|
|||
51
services/cards-server/src/middleware/optional-auth.ts
Normal file
51
services/cards-server/src/middleware/optional-auth.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
/**
|
||||
* Optional JWT — sets `c.get('user')` when a valid Bearer token is
|
||||
* present, but never rejects the request. Routes that need an
|
||||
* authenticated user fall back to `null` and decide what to do
|
||||
* (most public endpoints just hide private fields; mutation endpoints
|
||||
* still throw 401 explicitly).
|
||||
*
|
||||
* Why a separate middleware? `jwtAuth` is the strict gate for write
|
||||
* paths — same JWKS, same algo, but rejecting early. `optionalAuth`
|
||||
* is the read-path companion: it lets cards-api.mana.how serve the
|
||||
* marketplace surface to anonymous browsers (search engines, anti-
|
||||
* link-rot, share-link previews) while still recognising signed-in
|
||||
* users for star/follow state.
|
||||
*/
|
||||
|
||||
import type { MiddlewareHandler } from 'hono';
|
||||
import { createRemoteJWKSet, jwtVerify } from 'jose';
|
||||
import type { AuthUser } from './jwt-auth';
|
||||
|
||||
let jwks: ReturnType<typeof createRemoteJWKSet> | null = null;
|
||||
function getJwks(authUrl: string) {
|
||||
if (!jwks) jwks = createRemoteJWKSet(new URL('/api/auth/jwks', authUrl));
|
||||
return jwks;
|
||||
}
|
||||
|
||||
export function optionalAuth(authUrl: string): MiddlewareHandler {
|
||||
return async (c, next) => {
|
||||
const authHeader = c.req.header('Authorization');
|
||||
if (!authHeader?.startsWith('Bearer ')) {
|
||||
await next();
|
||||
return;
|
||||
}
|
||||
const token = authHeader.slice(7);
|
||||
try {
|
||||
const { payload } = await jwtVerify(token, getJwks(authUrl), {
|
||||
issuer: authUrl,
|
||||
audience: 'mana',
|
||||
});
|
||||
const user: AuthUser = {
|
||||
userId: payload.sub || '',
|
||||
email: (payload.email as string) || '',
|
||||
role: (payload.role as string) || 'user',
|
||||
};
|
||||
c.set('user', user);
|
||||
} catch {
|
||||
// Bad token = anonymous; the strict middleware rejects on
|
||||
// write paths.
|
||||
}
|
||||
await next();
|
||||
};
|
||||
}
|
||||
|
|
@ -2,7 +2,7 @@ import { Hono } from 'hono';
|
|||
import { z } from 'zod';
|
||||
import type { AuthUser } from '../middleware/jwt-auth';
|
||||
import type { AuthorService } from '../services/authors';
|
||||
import { BadRequestError } from '../lib/errors';
|
||||
import { BadRequestError, UnauthorizedError } from '../lib/errors';
|
||||
|
||||
const upsertSchema = z.object({
|
||||
slug: z.string(),
|
||||
|
|
@ -12,11 +12,17 @@ const upsertSchema = z.object({
|
|||
pseudonym: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export function createAuthorRoutes(authorService: AuthorService) {
|
||||
const router = new Hono<{ Variables: { user: AuthUser } }>();
|
||||
function requireUser(user: AuthUser | undefined): AuthUser {
|
||||
if (!user || !user.userId) throw new UnauthorizedError();
|
||||
return user;
|
||||
}
|
||||
|
||||
export function createAuthorRoutes(authorService: AuthorService) {
|
||||
const router = new Hono<{ Variables: { user?: AuthUser } }>();
|
||||
|
||||
// POST /me + GET /me are write/private — auth required.
|
||||
router.post('/me', async (c) => {
|
||||
const user = c.get('user');
|
||||
const user = requireUser(c.get('user'));
|
||||
const parsed = upsertSchema.safeParse(await c.req.json().catch(() => ({})));
|
||||
if (!parsed.success) throw new BadRequestError('Invalid body', parsed.error.format());
|
||||
const author = await authorService.upsertMe(user.userId, parsed.data);
|
||||
|
|
@ -24,11 +30,12 @@ export function createAuthorRoutes(authorService: AuthorService) {
|
|||
});
|
||||
|
||||
router.get('/me', async (c) => {
|
||||
const user = c.get('user');
|
||||
const user = requireUser(c.get('user'));
|
||||
const author = await authorService.getByUserId(user.userId);
|
||||
return c.json(author);
|
||||
});
|
||||
|
||||
// GET /:slug is public — anyone can look up an author profile.
|
||||
router.get('/:slug', async (c) => {
|
||||
const author = await authorService.getPublicBySlug(c.req.param('slug'));
|
||||
return c.json(author);
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { z } from 'zod';
|
|||
import type { AuthUser } from '../middleware/jwt-auth';
|
||||
import type { AuthorService } from '../services/authors';
|
||||
import type { DeckService } from '../services/decks';
|
||||
import { BadRequestError } from '../lib/errors';
|
||||
import { BadRequestError, UnauthorizedError } from '../lib/errors';
|
||||
|
||||
const cardTypes = [
|
||||
'basic',
|
||||
|
|
@ -38,11 +38,17 @@ const publishSchema = z.object({
|
|||
.max(5_000),
|
||||
});
|
||||
|
||||
export function createDeckRoutes(authorService: AuthorService, deckService: DeckService) {
|
||||
const router = new Hono<{ Variables: { user: AuthUser } }>();
|
||||
function requireUser(user: AuthUser | undefined): AuthUser {
|
||||
if (!user || !user.userId) throw new UnauthorizedError();
|
||||
return user;
|
||||
}
|
||||
|
||||
export function createDeckRoutes(authorService: AuthorService, deckService: DeckService) {
|
||||
const router = new Hono<{ Variables: { user?: AuthUser } }>();
|
||||
|
||||
// Init = write, auth required.
|
||||
router.post('/', async (c) => {
|
||||
const user = c.get('user');
|
||||
const user = requireUser(c.get('user'));
|
||||
await authorService.assertNotBanned(user.userId);
|
||||
const parsed = initSchema.safeParse(await c.req.json().catch(() => ({})));
|
||||
if (!parsed.success) throw new BadRequestError('Invalid body', parsed.error.format());
|
||||
|
|
@ -50,13 +56,14 @@ export function createDeckRoutes(authorService: AuthorService, deckService: Deck
|
|||
return c.json(deck, 201);
|
||||
});
|
||||
|
||||
// GET deck-by-slug is public — anyone can preview a deck.
|
||||
router.get('/:slug', async (c) => {
|
||||
const result = await deckService.getBySlug(c.req.param('slug'));
|
||||
return c.json(result);
|
||||
});
|
||||
|
||||
router.post('/:slug/publish', async (c) => {
|
||||
const user = c.get('user');
|
||||
const user = requireUser(c.get('user'));
|
||||
await authorService.assertNotBanned(user.userId);
|
||||
const parsed = publishSchema.safeParse(await c.req.json().catch(() => ({})));
|
||||
if (!parsed.success) throw new BadRequestError('Invalid body', parsed.error.format());
|
||||
|
|
|
|||
39
services/cards-server/src/routes/engagement.ts
Normal file
39
services/cards-server/src/routes/engagement.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import { Hono } from 'hono';
|
||||
import type { AuthUser } from '../middleware/jwt-auth';
|
||||
import type { EngagementService } from '../services/engagement';
|
||||
import { UnauthorizedError } from '../lib/errors';
|
||||
|
||||
function requireUser(user: AuthUser | undefined): AuthUser {
|
||||
if (!user || !user.userId) throw new UnauthorizedError();
|
||||
return user;
|
||||
}
|
||||
|
||||
export function createEngagementRoutes(service: EngagementService) {
|
||||
const router = new Hono<{ Variables: { user?: AuthUser } }>();
|
||||
|
||||
router.post('/decks/:slug/star', async (c) => {
|
||||
const user = requireUser(c.get('user'));
|
||||
await service.starDeck(user.userId, c.req.param('slug'));
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
router.delete('/decks/:slug/star', async (c) => {
|
||||
const user = requireUser(c.get('user'));
|
||||
await service.unstarDeck(user.userId, c.req.param('slug'));
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
router.post('/authors/:slug/follow', async (c) => {
|
||||
const user = requireUser(c.get('user'));
|
||||
await service.followAuthor(user.userId, c.req.param('slug'));
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
router.delete('/authors/:slug/follow', async (c) => {
|
||||
const user = requireUser(c.get('user'));
|
||||
await service.unfollowAuthor(user.userId, c.req.param('slug'));
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
40
services/cards-server/src/routes/explore.ts
Normal file
40
services/cards-server/src/routes/explore.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import { Hono } from 'hono';
|
||||
import type { AuthUser } from '../middleware/jwt-auth';
|
||||
import type { ExploreService, SortOption } from '../services/explore';
|
||||
|
||||
const sorts: SortOption[] = ['recent', 'popular', 'trending'];
|
||||
|
||||
export function createExploreRoutes(service: ExploreService) {
|
||||
const router = new Hono<{ Variables: { user?: AuthUser } }>();
|
||||
|
||||
router.get('/explore', async (c) => {
|
||||
const result = await service.explore();
|
||||
return c.json(result);
|
||||
});
|
||||
|
||||
router.get('/decks', async (c) => {
|
||||
const url = new URL(c.req.url);
|
||||
const sortParam = url.searchParams.get('sort');
|
||||
const sort = sorts.includes(sortParam as SortOption) ? (sortParam as SortOption) : 'recent';
|
||||
const limit = parseInt(url.searchParams.get('limit') ?? '20', 10);
|
||||
const offset = parseInt(url.searchParams.get('offset') ?? '0', 10);
|
||||
|
||||
const result = await service.browse({
|
||||
q: url.searchParams.get('q') ?? undefined,
|
||||
tag: url.searchParams.get('tag') ?? undefined,
|
||||
language: url.searchParams.get('lang') ?? undefined,
|
||||
authorSlug: url.searchParams.get('author') ?? undefined,
|
||||
sort,
|
||||
limit,
|
||||
offset,
|
||||
});
|
||||
return c.json(result);
|
||||
});
|
||||
|
||||
router.get('/tags', async (c) => {
|
||||
const tree = await service.tagTree();
|
||||
return c.json(tree);
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
79
services/cards-server/src/services/engagement.ts
Normal file
79
services/cards-server/src/services/engagement.ts
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
/**
|
||||
* Star + Follow primitives. Both are idempotent and safe to retry.
|
||||
*/
|
||||
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
import type { Database } from '../db/connection';
|
||||
import { authorFollows, authors, deckStars, publicDecks } from '../db/schema';
|
||||
import { ConflictError, NotFoundError } from '../lib/errors';
|
||||
|
||||
export class EngagementService {
|
||||
constructor(private readonly db: Database) {}
|
||||
|
||||
async starDeck(userId: string, deckSlug: string) {
|
||||
const deck = await this.db.query.publicDecks.findFirst({
|
||||
where: eq(publicDecks.slug, deckSlug),
|
||||
});
|
||||
if (!deck) throw new NotFoundError('Deck not found');
|
||||
await this.db.insert(deckStars).values({ userId, deckId: deck.id }).onConflictDoNothing();
|
||||
}
|
||||
|
||||
async unstarDeck(userId: string, deckSlug: string) {
|
||||
const deck = await this.db.query.publicDecks.findFirst({
|
||||
where: eq(publicDecks.slug, deckSlug),
|
||||
});
|
||||
if (!deck) throw new NotFoundError('Deck not found');
|
||||
await this.db
|
||||
.delete(deckStars)
|
||||
.where(and(eq(deckStars.userId, userId), eq(deckStars.deckId, deck.id)));
|
||||
}
|
||||
|
||||
async isDeckStarred(userId: string, deckSlug: string): Promise<boolean> {
|
||||
const row = await this.db
|
||||
.select({ id: deckStars.deckId })
|
||||
.from(deckStars)
|
||||
.innerJoin(publicDecks, eq(publicDecks.id, deckStars.deckId))
|
||||
.where(and(eq(deckStars.userId, userId), eq(publicDecks.slug, deckSlug)))
|
||||
.limit(1);
|
||||
return row.length > 0;
|
||||
}
|
||||
|
||||
async followAuthor(followerUserId: string, authorSlug: string) {
|
||||
const author = await this.db.query.authors.findFirst({
|
||||
where: eq(authors.slug, authorSlug),
|
||||
});
|
||||
if (!author) throw new NotFoundError('Author not found');
|
||||
if (author.userId === followerUserId) {
|
||||
throw new ConflictError('You cannot follow yourself');
|
||||
}
|
||||
await this.db
|
||||
.insert(authorFollows)
|
||||
.values({ followerUserId, authorUserId: author.userId })
|
||||
.onConflictDoNothing();
|
||||
}
|
||||
|
||||
async unfollowAuthor(followerUserId: string, authorSlug: string) {
|
||||
const author = await this.db.query.authors.findFirst({
|
||||
where: eq(authors.slug, authorSlug),
|
||||
});
|
||||
if (!author) throw new NotFoundError('Author not found');
|
||||
await this.db
|
||||
.delete(authorFollows)
|
||||
.where(
|
||||
and(
|
||||
eq(authorFollows.followerUserId, followerUserId),
|
||||
eq(authorFollows.authorUserId, author.userId)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
async isFollowing(followerUserId: string, authorSlug: string): Promise<boolean> {
|
||||
const row = await this.db
|
||||
.select({ id: authorFollows.authorUserId })
|
||||
.from(authorFollows)
|
||||
.innerJoin(authors, eq(authors.userId, authorFollows.authorUserId))
|
||||
.where(and(eq(authorFollows.followerUserId, followerUserId), eq(authors.slug, authorSlug)))
|
||||
.limit(1);
|
||||
return row.length > 0;
|
||||
}
|
||||
}
|
||||
195
services/cards-server/src/services/explore.ts
Normal file
195
services/cards-server/src/services/explore.ts
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
/**
|
||||
* Discovery service — browse, search, featured, trending, per-author
|
||||
* deck lists, tag hierarchy. Pure read-only.
|
||||
*
|
||||
* Search uses Postgres `to_tsvector` over (title, description) so we
|
||||
* don't depend on a separate index for Phase γ; Meilisearch lands in
|
||||
* Phase ι if/when this becomes the bottleneck. Trending = simple
|
||||
* recent-stars-velocity over the last 7 days; gamed at small N, fine
|
||||
* once volume picks up — replaceable without API changes.
|
||||
*/
|
||||
|
||||
import { and, desc, eq, gte, ilike, isNull, or, sql, count } from 'drizzle-orm';
|
||||
import type { Database } from '../db/connection';
|
||||
import {
|
||||
authors,
|
||||
deckStars,
|
||||
deckSubscriptions,
|
||||
deckTags,
|
||||
publicDecks,
|
||||
publicDeckVersions,
|
||||
tagDefinitions,
|
||||
} from '../db/schema';
|
||||
|
||||
export interface DeckListEntry {
|
||||
slug: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
language: string | null;
|
||||
license: string;
|
||||
priceCredits: number;
|
||||
cardCount: number;
|
||||
starCount: number;
|
||||
subscriberCount: number;
|
||||
isFeatured: boolean;
|
||||
createdAt: Date;
|
||||
owner: { slug: string; displayName: string; verifiedMana: boolean; verifiedCommunity: boolean };
|
||||
}
|
||||
|
||||
const SORT_OPTIONS = ['recent', 'popular', 'trending'] as const;
|
||||
export type SortOption = (typeof SORT_OPTIONS)[number];
|
||||
|
||||
export interface BrowseFilter {
|
||||
q?: string;
|
||||
tag?: string;
|
||||
language?: string;
|
||||
authorSlug?: string;
|
||||
sort?: SortOption;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
export class ExploreService {
|
||||
constructor(private readonly db: Database) {}
|
||||
|
||||
async browse(filter: BrowseFilter): Promise<{ items: DeckListEntry[]; total: number }> {
|
||||
const limit = Math.min(filter.limit ?? 20, 100);
|
||||
const offset = filter.offset ?? 0;
|
||||
const sort = filter.sort ?? 'recent';
|
||||
|
||||
// Base join: deck × owner-author × latest-version. We hit
|
||||
// Drizzle's relational query API for predictable joins instead
|
||||
// of building a giant select-with-joins by hand.
|
||||
const conditions = [eq(publicDecks.isTakedown, false)];
|
||||
if (filter.language) conditions.push(eq(publicDecks.language, filter.language));
|
||||
if (filter.q) {
|
||||
conditions.push(
|
||||
or(
|
||||
ilike(publicDecks.title, `%${filter.q}%`),
|
||||
ilike(publicDecks.description, `%${filter.q}%`)
|
||||
)!
|
||||
);
|
||||
}
|
||||
if (filter.authorSlug) {
|
||||
conditions.push(
|
||||
eq(
|
||||
publicDecks.ownerUserId,
|
||||
sql<string>`(SELECT user_id FROM cards.authors WHERE slug = ${filter.authorSlug} LIMIT 1)`
|
||||
)
|
||||
);
|
||||
}
|
||||
if (filter.tag) {
|
||||
conditions.push(
|
||||
sql`EXISTS (SELECT 1 FROM cards.deck_tags dt JOIN cards.tag_definitions td ON td.id = dt.tag_id WHERE dt.deck_id = ${publicDecks.id} AND td.slug = ${filter.tag})`
|
||||
);
|
||||
}
|
||||
|
||||
// Pre-compute counts via subqueries; avoids N+1.
|
||||
const starCount = sql<number>`(SELECT count(*)::int FROM cards.deck_stars s WHERE s.deck_id = ${publicDecks.id})`;
|
||||
const subscriberCount = sql<number>`(SELECT count(*)::int FROM cards.deck_subscriptions s WHERE s.deck_id = ${publicDecks.id})`;
|
||||
const cardCountExpr = sql<number>`COALESCE((SELECT v.card_count FROM cards.deck_versions v WHERE v.id = ${publicDecks.latestVersionId}), 0)`;
|
||||
|
||||
const sortClause =
|
||||
sort === 'popular'
|
||||
? desc(starCount)
|
||||
: sort === 'trending'
|
||||
? desc(
|
||||
sql<number>`(SELECT count(*)::int FROM cards.deck_stars s WHERE s.deck_id = ${publicDecks.id} AND s.starred_at >= now() - interval '7 days')`
|
||||
)
|
||||
: desc(publicDecks.createdAt);
|
||||
|
||||
const baseQuery = this.db
|
||||
.select({
|
||||
slug: publicDecks.slug,
|
||||
title: publicDecks.title,
|
||||
description: publicDecks.description,
|
||||
language: publicDecks.language,
|
||||
license: publicDecks.license,
|
||||
priceCredits: publicDecks.priceCredits,
|
||||
cardCount: cardCountExpr,
|
||||
starCount,
|
||||
subscriberCount,
|
||||
isFeatured: publicDecks.isFeatured,
|
||||
createdAt: publicDecks.createdAt,
|
||||
ownerSlug: authors.slug,
|
||||
ownerDisplayName: authors.displayName,
|
||||
ownerVerifiedMana: authors.verifiedMana,
|
||||
ownerVerifiedCommunity: authors.verifiedCommunity,
|
||||
})
|
||||
.from(publicDecks)
|
||||
.innerJoin(authors, eq(authors.userId, publicDecks.ownerUserId))
|
||||
.where(and(...conditions))
|
||||
.orderBy(sortClause)
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
|
||||
const totalQuery = this.db
|
||||
.select({ value: count() })
|
||||
.from(publicDecks)
|
||||
.innerJoin(authors, eq(authors.userId, publicDecks.ownerUserId))
|
||||
.where(and(...conditions));
|
||||
|
||||
const [rows, totalResult] = await Promise.all([baseQuery, totalQuery]);
|
||||
|
||||
return {
|
||||
items: rows.map((r) => ({
|
||||
slug: r.slug,
|
||||
title: r.title,
|
||||
description: r.description,
|
||||
language: r.language,
|
||||
license: r.license,
|
||||
priceCredits: r.priceCredits,
|
||||
cardCount: Number(r.cardCount),
|
||||
starCount: Number(r.starCount),
|
||||
subscriberCount: Number(r.subscriberCount),
|
||||
isFeatured: r.isFeatured,
|
||||
createdAt: r.createdAt,
|
||||
owner: {
|
||||
slug: r.ownerSlug,
|
||||
displayName: r.ownerDisplayName,
|
||||
verifiedMana: r.ownerVerifiedMana,
|
||||
verifiedCommunity: r.ownerVerifiedCommunity,
|
||||
},
|
||||
})),
|
||||
total: totalResult[0]?.value ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
/** Featured + Trending side-by-side for the /explore landing. */
|
||||
async explore(): Promise<{ featured: DeckListEntry[]; trending: DeckListEntry[] }> {
|
||||
const [featuredResult, trendingResult] = await Promise.all([
|
||||
this.browse({ sort: 'popular', limit: 8 }).then((r) =>
|
||||
r.items.filter((d) => d.isFeatured).slice(0, 8)
|
||||
),
|
||||
this.browse({ sort: 'trending', limit: 8 }),
|
||||
]);
|
||||
return { featured: featuredResult, trending: trendingResult.items };
|
||||
}
|
||||
|
||||
async tagTree() {
|
||||
const rows = await this.db
|
||||
.select()
|
||||
.from(tagDefinitions)
|
||||
.orderBy(tagDefinitions.parentId, tagDefinitions.name);
|
||||
return rows;
|
||||
}
|
||||
|
||||
async curatedTagsOnly() {
|
||||
return this.db
|
||||
.select()
|
||||
.from(tagDefinitions)
|
||||
.where(eq(tagDefinitions.curated, true))
|
||||
.orderBy(tagDefinitions.name);
|
||||
}
|
||||
|
||||
// Silence unused-binding lint for imports that downstream queries
|
||||
// will pull in.
|
||||
_keepAlive() {
|
||||
void deckSubscriptions;
|
||||
void deckStars;
|
||||
void deckTags;
|
||||
void publicDeckVersions;
|
||||
void isNull;
|
||||
void gte;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue