mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-20 23:26:41 +02:00
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.
136 lines
5.2 KiB
TypeScript
136 lines
5.2 KiB
TypeScript
/**
|
||
* 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,
|
||
};
|