managarten/services/cards-server/src/index.ts
Till JS 5dbc9ace2d feat(cards): Phase ζ.1 — Paid decks via mana-credits
Server (cards-server):
- lib/credits.ts: thin internal-API client for mana-credits
  (reserve / commit / refund-reservation / grant). Service-to-
  service via X-Service-Key. Throws InsufficientCreditsError
  separately so the buy flow can branch on UX.
- services/purchases.ts: 4-step purchase pipeline: reserve →
  insert deck_purchases row → commit reservation → grant
  author share + insert author_payouts. Idempotent on
  (buyer, deck) so a refresh-spam-click can't double-charge.
  Verified-mana authors get the 90/10 split, others 80/20
  (already in config). Refunds intentionally out of scope —
  see MARKETPLACE_PLAN §13a.
- routes/purchases.ts: POST /v1/decks/:slug/purchase,
  GET /v1/me/purchases, GET /v1/authors/me/payouts.
- decks.bySlug now returns hasPurchased (null when anonymous,
  bool when authed) so the deck-detail page can pick the right
  CTA.
- subscriptions.subscribe now blocks paid decks unless the
  caller has a non-refunded purchase row (owner exempt for
  testing).
- Notify: author gets a "Verkauf"-Email at grant time, with a
  deterministic externalId for dedup.

Frontend (cards-web):
- /d/<slug> shows "Kaufen für N 💎" instead of "Abonnieren"
  when paid + not yet bought; flips to subscribe path once
  purchased.
- /me/purchases page listing buyer history + (when present)
  author-payout history. Linked from the top nav.
2026-05-07 23:10:18 +02:00

136 lines
5.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* cards-server — Cards Marketplace + Community backend.
*
* Hono + Bun. Owns published decks, versions, subscriptions, forks,
* pull-requests, discussions, moderation, and the credits-based
* author payout pipeline.
*
* See apps/cards/docs/MARKETPLACE_PLAN.md for the full design.
*/
import { Hono } from 'hono';
import { cors } from 'hono/cors';
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 { SubscriptionService } from './services/subscriptions';
import { PullRequestService } from './services/pull-requests';
import { DiscussionService } from './services/discussions';
import { PurchaseService } from './services/purchases';
import { createAuthorRoutes } from './routes/authors';
import { createDeckRoutes } from './routes/decks';
import { createExploreRoutes } from './routes/explore';
import { createEngagementRoutes } from './routes/engagement';
import { createSubscriptionRoutes } from './routes/subscriptions';
import { createPullRequestRoutes } from './routes/pull-requests';
import { createDiscussionRoutes } from './routes/discussions';
import { createPurchaseRoutes } from './routes/purchases';
import { createNotifyClient } from './lib/notify';
import { createCreditsClient } from './lib/credits';
// ─── Bootstrap ──────────────────────────────────────────────
const config = loadConfig();
const db = getDb(config.databaseUrl);
const notify = createNotifyClient({
url: config.manaNotifyUrl,
serviceKey: config.serviceKey,
});
const credits = createCreditsClient({
url: config.manaCreditsUrl,
serviceKey: config.serviceKey,
});
const authorService = new AuthorService(db);
const deckService = new DeckService(db, config.manaLlmUrl);
const exploreService = new ExploreService(db);
const engagementService = new EngagementService(db);
const subscriptionService = new SubscriptionService(db);
const pullRequestService = new PullRequestService(db, notify);
const discussionService = new DiscussionService(db);
const purchaseService = new PurchaseService(
db,
credits,
{
standardAuthorBps: config.authorPayout.standardAuthorBps,
verifiedAuthorBps: config.authorPayout.verifiedAuthorBps,
},
notify
);
// ─── App ────────────────────────────────────────────────────
const app = new Hono<{ Variables: { user?: AuthUser } }>();
app.onError(errorHandler);
app.use(
'*',
cors({
origin: config.cors.origins,
credentials: true,
})
);
// Health (no auth)
app.route('/health', healthRoutes);
// Versioned API surface — additive-only changes within v1, breaking
// changes go to /v2 (MARKETPLACE_PLAN §3 architecture principle 1).
//
// 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 } }>();
// 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('/', createSubscriptionRoutes(subscriptionService));
v1.route('/', createPullRequestRoutes(pullRequestService));
v1.route('/', createDiscussionRoutes(discussionService));
v1.route('/', createPurchaseRoutes(purchaseService));
v1.route('/authors', createAuthorRoutes(authorService));
v1.route('/decks', createDeckRoutes(authorService, deckService, purchaseService));
v1.get('/', (c) =>
c.json({
service: 'cards-server',
version: 1,
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}`);
export default {
port: config.port,
fetch: app.fetch,
};