mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:41:09 +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 { loadConfig } from './config';
|
||||||
import { getDb } from './db/connection';
|
import { getDb } from './db/connection';
|
||||||
import { jwtAuth, type AuthUser } from './middleware/jwt-auth';
|
import { jwtAuth, type AuthUser } from './middleware/jwt-auth';
|
||||||
|
import { optionalAuth } from './middleware/optional-auth';
|
||||||
import { healthRoutes } from './routes/health';
|
import { healthRoutes } from './routes/health';
|
||||||
import { AuthorService } from './services/authors';
|
import { AuthorService } from './services/authors';
|
||||||
import { DeckService } from './services/decks';
|
import { DeckService } from './services/decks';
|
||||||
|
import { ExploreService } from './services/explore';
|
||||||
|
import { EngagementService } from './services/engagement';
|
||||||
import { createAuthorRoutes } from './routes/authors';
|
import { createAuthorRoutes } from './routes/authors';
|
||||||
import { createDeckRoutes } from './routes/decks';
|
import { createDeckRoutes } from './routes/decks';
|
||||||
|
import { createExploreRoutes } from './routes/explore';
|
||||||
|
import { createEngagementRoutes } from './routes/engagement';
|
||||||
|
|
||||||
// ─── Bootstrap ──────────────────────────────────────────────
|
// ─── Bootstrap ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -27,10 +32,12 @@ const db = getDb(config.databaseUrl);
|
||||||
|
|
||||||
const authorService = new AuthorService(db);
|
const authorService = new AuthorService(db);
|
||||||
const deckService = new DeckService(db, config.manaLlmUrl);
|
const deckService = new DeckService(db, config.manaLlmUrl);
|
||||||
|
const exploreService = new ExploreService(db);
|
||||||
|
const engagementService = new EngagementService(db);
|
||||||
|
|
||||||
// ─── App ────────────────────────────────────────────────────
|
// ─── App ────────────────────────────────────────────────────
|
||||||
|
|
||||||
const app = new Hono<{ Variables: { user: AuthUser } }>();
|
const app = new Hono<{ Variables: { user?: AuthUser } }>();
|
||||||
|
|
||||||
app.onError(errorHandler);
|
app.onError(errorHandler);
|
||||||
app.use(
|
app.use(
|
||||||
|
|
@ -46,16 +53,26 @@ app.route('/health', healthRoutes);
|
||||||
|
|
||||||
// Versioned API surface — additive-only changes within v1, breaking
|
// Versioned API surface — additive-only changes within v1, breaking
|
||||||
// changes go to /v2 (MARKETPLACE_PLAN §3 architecture principle 1).
|
// 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
|
// Phase γ: public reads first — explore + browse + tags + author
|
||||||
// mutating endpoints in the same routers gate themselves by checking
|
// profile lookup + deck profile lookup. All read-only, no token
|
||||||
// for `c.get('user')`. Until we have that anonymous-aware middleware
|
// required, but a present token enables logged-in extras (star
|
||||||
// (Phase γ adds optionalAuth), every /v1 route gates on JWT — public
|
// state, follow state) once those flags land in the responses
|
||||||
// reads still work for any signed-in user, which covers the only
|
// (MARKETPLACE_PLAN phase γ.3).
|
||||||
// surface we have right now (author dashboard + deck CRUD).
|
v1.use('/*', optionalAuth(config.manaAuthUrl));
|
||||||
v1.use('/*', jwtAuth(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('/authors', createAuthorRoutes(authorService));
|
||||||
v1.route('/decks', createDeckRoutes(authorService, deckService));
|
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.',
|
message: 'See apps/cards/docs/MARKETPLACE_PLAN.md for the full plan.',
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
app.route('/v1', v1);
|
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 ────────────────────────────────────────────────
|
// ─── Listen ────────────────────────────────────────────────
|
||||||
|
|
||||||
console.log(`[cards-server] listening on :${config.port}`);
|
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 { z } from 'zod';
|
||||||
import type { AuthUser } from '../middleware/jwt-auth';
|
import type { AuthUser } from '../middleware/jwt-auth';
|
||||||
import type { AuthorService } from '../services/authors';
|
import type { AuthorService } from '../services/authors';
|
||||||
import { BadRequestError } from '../lib/errors';
|
import { BadRequestError, UnauthorizedError } from '../lib/errors';
|
||||||
|
|
||||||
const upsertSchema = z.object({
|
const upsertSchema = z.object({
|
||||||
slug: z.string(),
|
slug: z.string(),
|
||||||
|
|
@ -12,11 +12,17 @@ const upsertSchema = z.object({
|
||||||
pseudonym: z.boolean().optional(),
|
pseudonym: z.boolean().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export function createAuthorRoutes(authorService: AuthorService) {
|
function requireUser(user: AuthUser | undefined): AuthUser {
|
||||||
const router = new Hono<{ Variables: { user: 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) => {
|
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(() => ({})));
|
const parsed = upsertSchema.safeParse(await c.req.json().catch(() => ({})));
|
||||||
if (!parsed.success) throw new BadRequestError('Invalid body', parsed.error.format());
|
if (!parsed.success) throw new BadRequestError('Invalid body', parsed.error.format());
|
||||||
const author = await authorService.upsertMe(user.userId, parsed.data);
|
const author = await authorService.upsertMe(user.userId, parsed.data);
|
||||||
|
|
@ -24,11 +30,12 @@ export function createAuthorRoutes(authorService: AuthorService) {
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get('/me', async (c) => {
|
router.get('/me', async (c) => {
|
||||||
const user = c.get('user');
|
const user = requireUser(c.get('user'));
|
||||||
const author = await authorService.getByUserId(user.userId);
|
const author = await authorService.getByUserId(user.userId);
|
||||||
return c.json(author);
|
return c.json(author);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// GET /:slug is public — anyone can look up an author profile.
|
||||||
router.get('/:slug', async (c) => {
|
router.get('/:slug', async (c) => {
|
||||||
const author = await authorService.getPublicBySlug(c.req.param('slug'));
|
const author = await authorService.getPublicBySlug(c.req.param('slug'));
|
||||||
return c.json(author);
|
return c.json(author);
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import { z } from 'zod';
|
||||||
import type { AuthUser } from '../middleware/jwt-auth';
|
import type { AuthUser } from '../middleware/jwt-auth';
|
||||||
import type { AuthorService } from '../services/authors';
|
import type { AuthorService } from '../services/authors';
|
||||||
import type { DeckService } from '../services/decks';
|
import type { DeckService } from '../services/decks';
|
||||||
import { BadRequestError } from '../lib/errors';
|
import { BadRequestError, UnauthorizedError } from '../lib/errors';
|
||||||
|
|
||||||
const cardTypes = [
|
const cardTypes = [
|
||||||
'basic',
|
'basic',
|
||||||
|
|
@ -38,11 +38,17 @@ const publishSchema = z.object({
|
||||||
.max(5_000),
|
.max(5_000),
|
||||||
});
|
});
|
||||||
|
|
||||||
export function createDeckRoutes(authorService: AuthorService, deckService: DeckService) {
|
function requireUser(user: AuthUser | undefined): AuthUser {
|
||||||
const router = new Hono<{ Variables: { user: 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) => {
|
router.post('/', async (c) => {
|
||||||
const user = c.get('user');
|
const user = requireUser(c.get('user'));
|
||||||
await authorService.assertNotBanned(user.userId);
|
await authorService.assertNotBanned(user.userId);
|
||||||
const parsed = initSchema.safeParse(await c.req.json().catch(() => ({})));
|
const parsed = initSchema.safeParse(await c.req.json().catch(() => ({})));
|
||||||
if (!parsed.success) throw new BadRequestError('Invalid body', parsed.error.format());
|
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);
|
return c.json(deck, 201);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// GET deck-by-slug is public — anyone can preview a deck.
|
||||||
router.get('/:slug', async (c) => {
|
router.get('/:slug', async (c) => {
|
||||||
const result = await deckService.getBySlug(c.req.param('slug'));
|
const result = await deckService.getBySlug(c.req.param('slug'));
|
||||||
return c.json(result);
|
return c.json(result);
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post('/:slug/publish', async (c) => {
|
router.post('/:slug/publish', async (c) => {
|
||||||
const user = c.get('user');
|
const user = requireUser(c.get('user'));
|
||||||
await authorService.assertNotBanned(user.userId);
|
await authorService.assertNotBanned(user.userId);
|
||||||
const parsed = publishSchema.safeParse(await c.req.json().catch(() => ({})));
|
const parsed = publishSchema.safeParse(await c.req.json().catch(() => ({})));
|
||||||
if (!parsed.success) throw new BadRequestError('Invalid body', parsed.error.format());
|
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