Phase 12 R2: Marketplace-Backend α + β — Authors + Deck-Init + Publish
Routes (additiv unter /api/v1/marketplace/*): - POST/GET /authors/me — eigenes Author-Profil anlegen/updaten/lesen - GET /authors/:slug — public Profile-Lookup (banned-reason gestrippt) - POST /decks — Deck-Init (Slug-Validation + Pflicht-Author-Profil + CHECK auf paid + Pro-License) - POST /decks/:slug/publish — Versions-Snapshot mit per-Karte cardContentHash aus @cards/domain, per-Version-Hash, AI-Mod-Stub-Log, atomarer latest_version_id-Bump in Drizzle-Transaction - PATCH /decks/:slug — Metadaten-Update (Owner-Only) - GET /decks/:slug — Public-Detail mit optional-auth-Middleware Geport aus cards-decommission-base:services/cards-server/, mit Greenfield-Anpassungen: - Hashing über @cards/domain.cardContentHash (gemeinsame SoT zwischen privatem cards.cards und marketplace.deck_cards), per- Version-Hash als SHA-256 über sortierte Karten-Hashes mit Ord-Prefix - AI-Moderation als R2-Stub (pass+rationale+model='stub'), echte mana-llm-Anbindung in späterer Welle - Auth-Middleware-Shape an Greenfield (userId/tier/authMode in c.get(...) statt user-Object), optional-auth als Schwester für anonymen Public-Read - Hono-typing: outer Marketplace-Decks-Router ist Partial<AuthVars> weil Public-GET kein JWT braucht; Auth-Subroute ist strict Lese-Referenz: - 3331 LOC altes cards-server-Code (routes, services, middleware, lib) unter docs/marketplace/archive/code/ archiviert. Read-only, nicht im Build-Path. Verifikation: - 16 neue Vitest-Tests (Slug + Version-Hash), 72 gesamt grün - type-check 0 errors - E2E-Smoke gegen lokale cards-api: Cardecky-Author + Deck r2-stoische-ethik mit 3 Karten v1.0.0 (basic + basic + cloze), per-Karten-Hashes geschrieben, ai_moderation_log-Row da, semver-409 + paid-422-Errors verifiziert. Smoke-Daten danach aufgeräumt. Verbleibend für R3+: Discovery (explore + search), Engagement (stars/ subscribe/fork), Smart-Merge mit FSRS-State-Erhalt; danach R4 PRs + Card-Discussions, R5 Frontend-Routes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
9a7068dd19
commit
7dbbf63523
40 changed files with 4004 additions and 1 deletions
72
docs/marketplace/archive/code/config.ts
Normal file
72
docs/marketplace/archive/code/config.ts
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
/**
|
||||
* Runtime config — read once at startup, validated with sensible
|
||||
* dev-friendly defaults but loud in prod when secrets are missing.
|
||||
*/
|
||||
|
||||
export interface Config {
|
||||
port: number;
|
||||
databaseUrl: string;
|
||||
manaAuthUrl: string;
|
||||
manaCreditsUrl: string;
|
||||
manaLlmUrl: string;
|
||||
manaMediaUrl: string;
|
||||
manaNotifyUrl: string;
|
||||
serviceKey: string;
|
||||
cors: { origins: string[] };
|
||||
authorPayout: {
|
||||
standardAuthorBps: number;
|
||||
verifiedAuthorBps: number;
|
||||
};
|
||||
communityVerifiedThresholds: {
|
||||
stars: number;
|
||||
featuredDecks: number;
|
||||
activeSubscribers: number;
|
||||
};
|
||||
}
|
||||
|
||||
function getEnv(key: string, fallback?: string): string {
|
||||
const v = process.env[key];
|
||||
if (v && v.length > 0) return v;
|
||||
if (fallback !== undefined) return fallback;
|
||||
throw new Error(`Missing required env var: ${key}`);
|
||||
}
|
||||
|
||||
function getEnvNumber(key: string, fallback: number): number {
|
||||
const v = process.env[key];
|
||||
if (!v) return fallback;
|
||||
const n = Number(v);
|
||||
if (Number.isNaN(n)) throw new Error(`${key} is not a number: ${v}`);
|
||||
return n;
|
||||
}
|
||||
|
||||
export function loadConfig(): Config {
|
||||
const inProd = process.env.NODE_ENV === 'production';
|
||||
|
||||
return {
|
||||
port: getEnvNumber('PORT', 3072),
|
||||
databaseUrl: getEnv(
|
||||
'DATABASE_URL',
|
||||
inProd ? undefined : 'postgresql://mana:devpassword@localhost:5432/mana_platform'
|
||||
),
|
||||
manaAuthUrl: getEnv('MANA_AUTH_URL', 'http://localhost:3001'),
|
||||
manaCreditsUrl: getEnv('MANA_CREDITS_URL', 'http://localhost:3061'),
|
||||
manaLlmUrl: getEnv('MANA_LLM_URL', 'http://localhost:3025'),
|
||||
manaMediaUrl: getEnv('MANA_MEDIA_URL', 'http://localhost:3015'),
|
||||
manaNotifyUrl: getEnv('MANA_NOTIFY_URL', 'http://localhost:3040'),
|
||||
serviceKey: getEnv('MANA_SERVICE_KEY', inProd ? undefined : 'dev-service-key'),
|
||||
cors: {
|
||||
origins: getEnv('CORS_ORIGINS', 'http://localhost:5173,http://localhost:5180').split(','),
|
||||
},
|
||||
authorPayout: {
|
||||
// 80/20 standard, 90/10 for verified-mana authors. Stored in
|
||||
// basis-points so we can tune later without code change.
|
||||
standardAuthorBps: getEnvNumber('AUTHOR_PAYOUT_STANDARD_BPS', 8000),
|
||||
verifiedAuthorBps: getEnvNumber('AUTHOR_PAYOUT_VERIFIED_BPS', 9000),
|
||||
},
|
||||
communityVerifiedThresholds: {
|
||||
stars: getEnvNumber('COMMUNITY_VERIFY_STARS', 500),
|
||||
featuredDecks: getEnvNumber('COMMUNITY_VERIFY_FEATURED', 3),
|
||||
activeSubscribers: getEnvNumber('COMMUNITY_VERIFY_SUBSCRIBERS', 200),
|
||||
},
|
||||
};
|
||||
}
|
||||
140
docs/marketplace/archive/code/index.ts
Normal file
140
docs/marketplace/archive/code/index.ts
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
/**
|
||||
* 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 { ModerationService } from './services/moderation';
|
||||
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 { createModerationRoutes } from './routes/moderation';
|
||||
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
|
||||
);
|
||||
const moderationService = new ModerationService(db, 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('/', createModerationRoutes(moderationService));
|
||||
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,
|
||||
};
|
||||
132
docs/marketplace/archive/code/lib/ai-moderation.ts
Normal file
132
docs/marketplace/archive/code/lib/ai-moderation.ts
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
/**
|
||||
* AI moderation first-pass via mana-llm.
|
||||
*
|
||||
* Asks the model to classify a deck's content into one of three
|
||||
* verdicts: pass, flag, block. `flag` means a human reviewer should
|
||||
* look at it before the deck goes public; `block` means refuse the
|
||||
* publish outright.
|
||||
*
|
||||
* Per MARKETPLACE_PLAN principle 6: "AI is moderator, not gatekeeper"
|
||||
* — `block` is only used for unambiguous offences (CSAM, real-world
|
||||
* doxxing). Anything ambiguous flows to human review.
|
||||
*
|
||||
* Fail-open: if mana-llm is unreachable or returns malformed JSON,
|
||||
* the verdict defaults to `flag` so the human reviewer catches it.
|
||||
* Better a slow publish than a quietly-skipped check.
|
||||
*/
|
||||
|
||||
const SYSTEM_PROMPT = `Du bist Inhalts-Moderator für eine Karteikarten-Plattform. Bewerte den vorgelegten Inhalt nach folgenden Kategorien:
|
||||
|
||||
- spam: Werbe-Spam, ohne erkennbaren Lerninhalt
|
||||
- copyright: offensichtliche, lange Lehrbuch-Auszüge ohne Quelle/Lizenzhinweis
|
||||
- nsfw: sexuell explizit, jugendgefährdend
|
||||
- misinformation: nachweislich falsche Fakten als Tatsachen präsentiert (außerhalb subjektiver Themen)
|
||||
- hate: Hassrede, Diskriminierung gegen geschützte Gruppen
|
||||
- csam: Material, das Minderjährige sexualisiert (führt IMMER zu block)
|
||||
|
||||
Antworte AUSSCHLIESSLICH mit einem JSON-Objekt:
|
||||
{"verdict":"pass|flag|block","categories":["..."],"rationale":"kurze Begründung"}
|
||||
|
||||
Regeln:
|
||||
- pass: keine Kategorien getroffen
|
||||
- flag: eine oder mehrere Kategorien außer csam
|
||||
- block: csam ODER unmissverständliche Kombination aus mehreren schweren Kategorien
|
||||
- Im Zweifel: flag (nicht block) — eine menschliche Moderatorin entscheidet final.`;
|
||||
|
||||
export interface ModerationVerdict {
|
||||
verdict: 'pass' | 'flag' | 'block';
|
||||
categories: string[];
|
||||
rationale: string;
|
||||
model: string;
|
||||
}
|
||||
|
||||
export interface ModerationInput {
|
||||
title: string;
|
||||
description?: string;
|
||||
cards: { fields: Record<string, string> }[];
|
||||
}
|
||||
|
||||
const MODEL = process.env.AI_MODERATION_MODEL || 'gpt-4o-mini';
|
||||
const MAX_CARDS_FOR_PROMPT = 50;
|
||||
|
||||
function buildPrompt(input: ModerationInput): string {
|
||||
const sample = input.cards.slice(0, MAX_CARDS_FOR_PROMPT).map((c, i) => {
|
||||
const fieldsStr = Object.entries(c.fields)
|
||||
.map(([k, v]) => ` ${k}: ${v}`)
|
||||
.join('\n');
|
||||
return `Karte ${i + 1}:\n${fieldsStr}`;
|
||||
});
|
||||
return [
|
||||
`Deck-Titel: ${input.title}`,
|
||||
input.description ? `Beschreibung: ${input.description}` : '',
|
||||
`Karten (${input.cards.length} insgesamt, erste ${sample.length} gezeigt):`,
|
||||
...sample,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n\n');
|
||||
}
|
||||
|
||||
function failOpen(rationale: string): ModerationVerdict {
|
||||
return {
|
||||
verdict: 'flag',
|
||||
categories: ['_internal'],
|
||||
rationale: `AI-Mod fail-open: ${rationale}`,
|
||||
model: MODEL,
|
||||
};
|
||||
}
|
||||
|
||||
function stripCodeFences(s: string): string {
|
||||
return s
|
||||
.replace(/^\s*```(?:json)?\s*/i, '')
|
||||
.replace(/\s*```\s*$/i, '')
|
||||
.trim();
|
||||
}
|
||||
|
||||
export async function moderateDeckContent(
|
||||
input: ModerationInput,
|
||||
llmUrl: string
|
||||
): Promise<ModerationVerdict> {
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(`${llmUrl}/v1/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
model: MODEL,
|
||||
temperature: 0,
|
||||
messages: [
|
||||
{ role: 'system', content: SYSTEM_PROMPT },
|
||||
{ role: 'user', content: buildPrompt(input) },
|
||||
],
|
||||
}),
|
||||
});
|
||||
} catch (e) {
|
||||
return failOpen(`network: ${(e as Error).message}`);
|
||||
}
|
||||
|
||||
if (!res.ok) return failOpen(`http ${res.status}`);
|
||||
|
||||
const json = (await res.json().catch(() => null)) as {
|
||||
choices?: { message?: { content?: string } }[];
|
||||
} | null;
|
||||
const raw = json?.choices?.[0]?.message?.content?.trim();
|
||||
if (!raw) return failOpen('empty response');
|
||||
|
||||
let parsed: { verdict?: unknown; categories?: unknown; rationale?: unknown };
|
||||
try {
|
||||
parsed = JSON.parse(stripCodeFences(raw));
|
||||
} catch {
|
||||
return failOpen('invalid JSON');
|
||||
}
|
||||
|
||||
const verdict =
|
||||
parsed.verdict === 'pass' || parsed.verdict === 'flag' || parsed.verdict === 'block'
|
||||
? parsed.verdict
|
||||
: 'flag';
|
||||
const categories = Array.isArray(parsed.categories)
|
||||
? parsed.categories.filter((c): c is string => typeof c === 'string')
|
||||
: [];
|
||||
const rationale = typeof parsed.rationale === 'string' ? parsed.rationale : '';
|
||||
|
||||
return { verdict, categories, rationale, model: MODEL };
|
||||
}
|
||||
80
docs/marketplace/archive/code/lib/credits.ts
Normal file
80
docs/marketplace/archive/code/lib/credits.ts
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
/**
|
||||
* Thin client for mana-credits internal API. Cards-server is a
|
||||
* service-to-service caller — the buyer's JWT does not flow through
|
||||
* here; we use the X-Service-Key channel instead so we can reserve
|
||||
* credits on a user's behalf, commit them after the purchase row is
|
||||
* written, and grant the author share in one server-side flow.
|
||||
*
|
||||
* Errors propagate as Error subclasses so the purchase service can
|
||||
* branch on `InsufficientCredits` vs. infra failures.
|
||||
*/
|
||||
|
||||
export class CreditsClientError extends Error {
|
||||
constructor(
|
||||
public readonly status: number,
|
||||
public readonly code: string,
|
||||
message: string
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'CreditsClientError';
|
||||
}
|
||||
}
|
||||
|
||||
export class InsufficientCreditsError extends CreditsClientError {
|
||||
constructor(message: string) {
|
||||
super(402, 'insufficient_credits', message);
|
||||
this.name = 'InsufficientCreditsError';
|
||||
}
|
||||
}
|
||||
|
||||
export interface CreditsClient {
|
||||
reserve(input: { userId: string; amount: number; reason: string }): Promise<{
|
||||
reservationId: string;
|
||||
balance: number;
|
||||
}>;
|
||||
commit(input: { reservationId: string; description?: string }): Promise<unknown>;
|
||||
refundReservation(input: { reservationId: string }): Promise<unknown>;
|
||||
grant(input: {
|
||||
userId: string;
|
||||
amount: number;
|
||||
reason: string;
|
||||
referenceId: string;
|
||||
description?: string;
|
||||
}): Promise<{ transactionId?: string; grantId?: string } | unknown>;
|
||||
}
|
||||
|
||||
export function createCreditsClient(opts: { url: string; serviceKey: string }): CreditsClient {
|
||||
async function call<T>(path: string, body: unknown): Promise<T> {
|
||||
const res = await fetch(`${opts.url}/api/v1/internal${path}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Service-Key': opts.serviceKey,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) {
|
||||
let msg = `${res.status} ${res.statusText}`;
|
||||
let code = 'credits_error';
|
||||
try {
|
||||
const j = (await res.json()) as { code?: string; message?: string };
|
||||
if (j.message) msg = j.message;
|
||||
if (j.code) code = j.code;
|
||||
} catch {
|
||||
/* keep default */
|
||||
}
|
||||
if (res.status === 402 || code === 'insufficient_credits') {
|
||||
throw new InsufficientCreditsError(msg);
|
||||
}
|
||||
throw new CreditsClientError(res.status, code, msg);
|
||||
}
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
|
||||
return {
|
||||
reserve: (input) => call('/credits/reserve', input),
|
||||
commit: (input) => call('/credits/commit', input),
|
||||
refundReservation: (input) => call('/credits/refund-reservation', input),
|
||||
grant: (input) => call('/credits/grant', input),
|
||||
};
|
||||
}
|
||||
63
docs/marketplace/archive/code/lib/errors.ts
Normal file
63
docs/marketplace/archive/code/lib/errors.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
/**
|
||||
* Domain errors — caught by `serviceErrorHandler` from @mana/shared-hono.
|
||||
*
|
||||
* The shared handler only translates Hono `HTTPException`s; anything
|
||||
* else degrades to 500. So our errors extend HTTPException directly
|
||||
* rather than maintaining a parallel hierarchy.
|
||||
*
|
||||
* `details` (e.g. zod issue tree) is passed via `cause` because the
|
||||
* shared handler picks that up and surfaces it in the JSON body.
|
||||
*/
|
||||
|
||||
import { HTTPException } from 'hono/http-exception';
|
||||
import type { ContentfulStatusCode } from 'hono/utils/http-status';
|
||||
|
||||
function makeException(
|
||||
status: ContentfulStatusCode,
|
||||
message: string,
|
||||
code?: string,
|
||||
details?: unknown
|
||||
) {
|
||||
return new HTTPException(status, {
|
||||
message,
|
||||
cause: details ? { code, details } : code ? { code } : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
export class HttpError extends HTTPException {}
|
||||
|
||||
export class UnauthorizedError extends HTTPException {
|
||||
constructor(message = 'Unauthorized') {
|
||||
super(401, { message, cause: { code: 'unauthorized' } });
|
||||
}
|
||||
}
|
||||
|
||||
export class ForbiddenError extends HTTPException {
|
||||
constructor(message = 'Forbidden') {
|
||||
super(403, { message, cause: { code: 'forbidden' } });
|
||||
}
|
||||
}
|
||||
|
||||
export class NotFoundError extends HTTPException {
|
||||
constructor(message = 'Not found') {
|
||||
super(404, { message, cause: { code: 'not_found' } });
|
||||
}
|
||||
}
|
||||
|
||||
export class ConflictError extends HTTPException {
|
||||
constructor(message = 'Conflict') {
|
||||
super(409, { message, cause: { code: 'conflict' } });
|
||||
}
|
||||
}
|
||||
|
||||
export class BadRequestError extends HTTPException {
|
||||
constructor(message = 'Bad request', details?: unknown) {
|
||||
super(400, {
|
||||
message,
|
||||
cause: details ? { code: 'bad_request', details } : { code: 'bad_request' },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Keep makeException exported in case future code wants the raw factory.
|
||||
export { makeException };
|
||||
44
docs/marketplace/archive/code/lib/hash.ts
Normal file
44
docs/marketplace/archive/code/lib/hash.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
/**
|
||||
* Content hashing — SHA-256 over canonicalized payloads. Drives:
|
||||
* - per-card `content_hash` (smart-merge across version bumps)
|
||||
* - per-version `content_hash` (cache + dedup detection)
|
||||
*
|
||||
* Canonicalization sorts object keys recursively so `{a:1,b:2}` and
|
||||
* `{b:2,a:1}` produce identical hashes. Without that, equivalent
|
||||
* payloads from different clients would diverge. Numbers/booleans
|
||||
* stringify naturally; strings are passed through verbatim.
|
||||
*/
|
||||
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
function canonical(value: unknown): unknown {
|
||||
if (value === null || typeof value !== 'object') return value;
|
||||
if (Array.isArray(value)) return value.map(canonical);
|
||||
const sorted: Record<string, unknown> = {};
|
||||
for (const key of Object.keys(value as Record<string, unknown>).sort()) {
|
||||
sorted[key] = canonical((value as Record<string, unknown>)[key]);
|
||||
}
|
||||
return sorted;
|
||||
}
|
||||
|
||||
function sha256(input: string): string {
|
||||
return createHash('sha256').update(input).digest('hex');
|
||||
}
|
||||
|
||||
/** Hash for a single card — based on (type, fields). */
|
||||
export function hashCard(card: { type: string; fields: Record<string, string> }): string {
|
||||
return sha256(JSON.stringify(canonical({ type: card.type, fields: card.fields })));
|
||||
}
|
||||
|
||||
/**
|
||||
* Hash for an ordered list of cards — version content hash. Order
|
||||
* matters because re-ordering is a meaningful change for the learner.
|
||||
*/
|
||||
export function hashVersionCards(
|
||||
cards: { type: string; fields: Record<string, string>; ord: number }[]
|
||||
): string {
|
||||
const ordered = [...cards].sort((a, b) => a.ord - b.ord);
|
||||
return sha256(
|
||||
JSON.stringify(ordered.map((c) => canonical({ type: c.type, fields: c.fields, ord: c.ord })))
|
||||
);
|
||||
}
|
||||
51
docs/marketplace/archive/code/lib/notify.ts
Normal file
51
docs/marketplace/archive/code/lib/notify.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
/**
|
||||
* Thin client for mana-notify. Fire-and-forget by design — a failed
|
||||
* notification must never roll back a domain action (PR merge, etc.),
|
||||
* so all callers `void` the promise and we just log on failure.
|
||||
*
|
||||
* `appId: 'cards'` keeps these notifications grouped in user
|
||||
* preferences so a learner can mute "PR activity" without losing
|
||||
* other Mana mail.
|
||||
*/
|
||||
|
||||
interface SendInput {
|
||||
channel: 'email' | 'push' | 'webhook';
|
||||
userId: string;
|
||||
subject: string;
|
||||
body: string;
|
||||
data?: Record<string, unknown>;
|
||||
externalId?: string;
|
||||
}
|
||||
|
||||
interface NotifyClient {
|
||||
send(input: SendInput): Promise<void>;
|
||||
}
|
||||
|
||||
export function createNotifyClient(opts: { url: string; serviceKey: string }): NotifyClient {
|
||||
return {
|
||||
async send(input) {
|
||||
try {
|
||||
await fetch(`${opts.url}/api/v1/notifications/send`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Service-Key': opts.serviceKey,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
channel: input.channel,
|
||||
appId: 'cards',
|
||||
userId: input.userId,
|
||||
subject: input.subject,
|
||||
body: input.body,
|
||||
data: input.data,
|
||||
externalId: input.externalId,
|
||||
}),
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn('[cards-server] notify failed', err);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export type { NotifyClient };
|
||||
58
docs/marketplace/archive/code/lib/slug.ts
Normal file
58
docs/marketplace/archive/code/lib/slug.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
/**
|
||||
* URL-safe slug helpers.
|
||||
*
|
||||
* `slugify` is best-effort — turns "Anna Lang!" into "anna-lang" — for
|
||||
* suggesting an initial slug. `validateSlug` is strict and what we
|
||||
* enforce on every write so the URL space stays predictable.
|
||||
*/
|
||||
|
||||
const MAX_SLUG_LEN = 60;
|
||||
const MIN_SLUG_LEN = 3;
|
||||
|
||||
const SLUG_RE = /^[a-z0-9](?:[a-z0-9-]{1,58}[a-z0-9])?$/;
|
||||
|
||||
const RESERVED_SLUGS = new Set([
|
||||
'admin',
|
||||
'api',
|
||||
'app',
|
||||
'auth',
|
||||
'docs',
|
||||
'explore',
|
||||
'feed',
|
||||
'help',
|
||||
'me',
|
||||
'mana',
|
||||
'new',
|
||||
'public',
|
||||
'search',
|
||||
'settings',
|
||||
'support',
|
||||
'system',
|
||||
'u',
|
||||
'd',
|
||||
'v1',
|
||||
'v2',
|
||||
]);
|
||||
|
||||
export function slugify(input: string): string {
|
||||
return input
|
||||
.normalize('NFKD')
|
||||
.replace(/[̀-ͯ]/g, '') // strip diacritics
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, MAX_SLUG_LEN);
|
||||
}
|
||||
|
||||
export interface SlugValidation {
|
||||
ok: boolean;
|
||||
reason?: 'too-short' | 'too-long' | 'invalid-chars' | 'reserved';
|
||||
}
|
||||
|
||||
export function validateSlug(slug: string): SlugValidation {
|
||||
if (slug.length < MIN_SLUG_LEN) return { ok: false, reason: 'too-short' };
|
||||
if (slug.length > MAX_SLUG_LEN) return { ok: false, reason: 'too-long' };
|
||||
if (!SLUG_RE.test(slug)) return { ok: false, reason: 'invalid-chars' };
|
||||
if (RESERVED_SLUGS.has(slug)) return { ok: false, reason: 'reserved' };
|
||||
return { ok: true };
|
||||
}
|
||||
56
docs/marketplace/archive/code/middleware/jwt-auth.ts
Normal file
56
docs/marketplace/archive/code/middleware/jwt-auth.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
/**
|
||||
* JWT authentication middleware — validates Bearer tokens via JWKS from
|
||||
* mana-auth (EdDSA, jose). Sets `c.set('user', { userId, email, role })`
|
||||
* on success.
|
||||
*
|
||||
* Mirrors the mana-credits middleware almost verbatim. Kept in-tree
|
||||
* rather than shared so we can evolve auth-related concerns (e.g.
|
||||
* audience claims) per service without coordination overhead.
|
||||
*/
|
||||
|
||||
import type { MiddlewareHandler } from 'hono';
|
||||
import { createRemoteJWKSet, jwtVerify } from 'jose';
|
||||
import { UnauthorizedError } from '../lib/errors';
|
||||
|
||||
let jwks: ReturnType<typeof createRemoteJWKSet> | null = null;
|
||||
|
||||
function getJwks(authUrl: string) {
|
||||
if (!jwks) {
|
||||
jwks = createRemoteJWKSet(new URL('/api/auth/jwks', authUrl));
|
||||
}
|
||||
return jwks;
|
||||
}
|
||||
|
||||
export interface AuthUser {
|
||||
userId: string;
|
||||
email: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
export function jwtAuth(authUrl: string): MiddlewareHandler {
|
||||
return async (c, next) => {
|
||||
const authHeader = c.req.header('Authorization');
|
||||
if (!authHeader?.startsWith('Bearer ')) {
|
||||
throw new UnauthorizedError('Missing or invalid Authorization header');
|
||||
}
|
||||
|
||||
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);
|
||||
await next();
|
||||
} catch {
|
||||
throw new UnauthorizedError('Invalid or expired token');
|
||||
}
|
||||
};
|
||||
}
|
||||
51
docs/marketplace/archive/code/middleware/optional-auth.ts
Normal file
51
docs/marketplace/archive/code/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 cardecky-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();
|
||||
};
|
||||
}
|
||||
18
docs/marketplace/archive/code/middleware/service-auth.ts
Normal file
18
docs/marketplace/archive/code/middleware/service-auth.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
/**
|
||||
* Service-to-service authentication. Used by `/api/v1/internal/*`
|
||||
* routes that other Mana services call (e.g. mana-credits-webhook
|
||||
* pinging us about a confirmed payment).
|
||||
*/
|
||||
|
||||
import type { MiddlewareHandler } from 'hono';
|
||||
import { UnauthorizedError } from '../lib/errors';
|
||||
|
||||
export function serviceAuth(expectedKey: string): MiddlewareHandler {
|
||||
return async (c, next) => {
|
||||
const key = c.req.header('X-Service-Key');
|
||||
if (!key || key !== expectedKey) {
|
||||
throw new UnauthorizedError('Invalid X-Service-Key');
|
||||
}
|
||||
await next();
|
||||
};
|
||||
}
|
||||
45
docs/marketplace/archive/code/routes/authors.ts
Normal file
45
docs/marketplace/archive/code/routes/authors.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import { Hono } from 'hono';
|
||||
import { z } from 'zod';
|
||||
import type { AuthUser } from '../middleware/jwt-auth';
|
||||
import type { AuthorService } from '../services/authors';
|
||||
import { BadRequestError, UnauthorizedError } from '../lib/errors';
|
||||
|
||||
const upsertSchema = z.object({
|
||||
slug: z.string(),
|
||||
displayName: z.string().min(1).max(80),
|
||||
bio: z.string().max(500).optional(),
|
||||
avatarUrl: z.string().url().max(512).optional(),
|
||||
pseudonym: z.boolean().optional(),
|
||||
});
|
||||
|
||||
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 = 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);
|
||||
return c.json(author);
|
||||
});
|
||||
|
||||
router.get('/me', async (c) => {
|
||||
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);
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
87
docs/marketplace/archive/code/routes/decks.ts
Normal file
87
docs/marketplace/archive/code/routes/decks.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
import { Hono } from 'hono';
|
||||
import { z } from 'zod';
|
||||
import type { AuthUser } from '../middleware/jwt-auth';
|
||||
import type { AuthorService } from '../services/authors';
|
||||
import type { DeckService } from '../services/decks';
|
||||
import type { PurchaseService } from '../services/purchases';
|
||||
import { BadRequestError, UnauthorizedError } from '../lib/errors';
|
||||
|
||||
const cardTypes = [
|
||||
'basic',
|
||||
'basic-reverse',
|
||||
'cloze',
|
||||
'type-in',
|
||||
'image-occlusion',
|
||||
'audio',
|
||||
'multiple-choice',
|
||||
] as const;
|
||||
|
||||
const initSchema = z.object({
|
||||
slug: z.string(),
|
||||
title: z.string().min(1).max(140),
|
||||
description: z.string().max(2000).optional(),
|
||||
language: z.string().min(2).max(8).optional(),
|
||||
license: z.string().max(64).optional(),
|
||||
priceCredits: z.number().int().min(0).max(10_000).optional(),
|
||||
});
|
||||
|
||||
const publishSchema = z.object({
|
||||
semver: z.string(),
|
||||
changelog: z.string().max(2000).optional(),
|
||||
cards: z
|
||||
.array(
|
||||
z.object({
|
||||
type: z.enum(cardTypes),
|
||||
fields: z.record(z.string(), z.string()),
|
||||
})
|
||||
)
|
||||
.min(1)
|
||||
.max(5_000),
|
||||
});
|
||||
|
||||
function requireUser(user: AuthUser | undefined): AuthUser {
|
||||
if (!user || !user.userId) throw new UnauthorizedError();
|
||||
return user;
|
||||
}
|
||||
|
||||
export function createDeckRoutes(
|
||||
authorService: AuthorService,
|
||||
deckService: DeckService,
|
||||
purchaseService?: PurchaseService
|
||||
) {
|
||||
const router = new Hono<{ Variables: { user?: AuthUser } }>();
|
||||
|
||||
// Init = write, auth required.
|
||||
router.post('/', async (c) => {
|
||||
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());
|
||||
const deck = await deckService.init(user.userId, parsed.data);
|
||||
return c.json(deck, 201);
|
||||
});
|
||||
|
||||
// GET deck-by-slug is public — anyone can preview a deck. If a
|
||||
// JWT is present we also annotate `hasPurchased` so the buy
|
||||
// button can be hidden for owners.
|
||||
router.get('/:slug', async (c) => {
|
||||
const result = await deckService.getBySlug(c.req.param('slug'));
|
||||
const user = c.get('user');
|
||||
const hasPurchased =
|
||||
user?.userId && purchaseService
|
||||
? await purchaseService.hasPurchased(user.userId, result.deck.id)
|
||||
: null;
|
||||
return c.json({ ...result, hasPurchased });
|
||||
});
|
||||
|
||||
router.post('/:slug/publish', async (c) => {
|
||||
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());
|
||||
const result = await deckService.publish(user.userId, c.req.param('slug'), parsed.data);
|
||||
return c.json(result, 201);
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
52
docs/marketplace/archive/code/routes/discussions.ts
Normal file
52
docs/marketplace/archive/code/routes/discussions.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import { Hono } from 'hono';
|
||||
import { z } from 'zod';
|
||||
import type { AuthUser } from '../middleware/jwt-auth';
|
||||
import type { DiscussionService } from '../services/discussions';
|
||||
import { BadRequestError, UnauthorizedError } from '../lib/errors';
|
||||
|
||||
function requireUser(user: AuthUser | undefined): AuthUser {
|
||||
if (!user || !user.userId) throw new UnauthorizedError();
|
||||
return user;
|
||||
}
|
||||
|
||||
const postSchema = z.object({
|
||||
deckSlug: z.string().min(1),
|
||||
body: z.string().min(1).max(4000),
|
||||
parentId: z.string().uuid().optional(),
|
||||
});
|
||||
|
||||
export function createDiscussionRoutes(service: DiscussionService) {
|
||||
const router = new Hono<{ Variables: { user?: AuthUser } }>();
|
||||
|
||||
router.get('/cards/:contentHash/discussions', async (c) => {
|
||||
const list = await service.listForCard(c.req.param('contentHash'));
|
||||
return c.json(list);
|
||||
});
|
||||
|
||||
router.get('/decks/:slug/discussion-counts', async (c) => {
|
||||
const counts = await service.countsForDeck(c.req.param('slug'));
|
||||
return c.json(counts);
|
||||
});
|
||||
|
||||
router.post('/cards/:contentHash/discussions', async (c) => {
|
||||
const user = requireUser(c.get('user'));
|
||||
const parsed = postSchema.safeParse(await c.req.json().catch(() => ({})));
|
||||
if (!parsed.success) throw new BadRequestError('Invalid body', parsed.error.format());
|
||||
const row = await service.post(
|
||||
user.userId,
|
||||
parsed.data.deckSlug,
|
||||
c.req.param('contentHash'),
|
||||
parsed.data.body,
|
||||
parsed.data.parentId
|
||||
);
|
||||
return c.json(row, 201);
|
||||
});
|
||||
|
||||
router.post('/discussions/:id/hide', async (c) => {
|
||||
const user = requireUser(c.get('user'));
|
||||
await service.hide(user.userId, c.req.param('id'));
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
39
docs/marketplace/archive/code/routes/engagement.ts
Normal file
39
docs/marketplace/archive/code/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
docs/marketplace/archive/code/routes/explore.ts
Normal file
40
docs/marketplace/archive/code/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;
|
||||
}
|
||||
96
docs/marketplace/archive/code/routes/moderation.ts
Normal file
96
docs/marketplace/archive/code/routes/moderation.ts
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
import { Hono } from 'hono';
|
||||
import { z } from 'zod';
|
||||
import type { AuthUser } from '../middleware/jwt-auth';
|
||||
import type { ModerationService } from '../services/moderation';
|
||||
import { BadRequestError, ForbiddenError, UnauthorizedError } from '../lib/errors';
|
||||
|
||||
function requireUser(user: AuthUser | undefined): AuthUser {
|
||||
if (!user || !user.userId) throw new UnauthorizedError();
|
||||
return user;
|
||||
}
|
||||
|
||||
function requireAdmin(user: AuthUser | undefined): AuthUser {
|
||||
const u = requireUser(user);
|
||||
if (u.role !== 'admin') throw new ForbiddenError('Admin role required');
|
||||
return u;
|
||||
}
|
||||
|
||||
const reportSchema = z.object({
|
||||
deckSlug: z.string().min(1),
|
||||
cardContentHash: z.string().min(1).optional(),
|
||||
category: z.enum(['spam', 'copyright', 'nsfw', 'misinformation', 'hate', 'other']),
|
||||
body: z.string().max(2000).optional(),
|
||||
});
|
||||
|
||||
const resolveSchema = z.object({
|
||||
action: z.enum(['dismiss', 'takedown', 'ban-author']),
|
||||
notes: z.string().max(1000).optional(),
|
||||
});
|
||||
|
||||
const takedownSchema = z.object({
|
||||
reason: z.string().max(1000).optional(),
|
||||
});
|
||||
|
||||
const verifySchema = z.object({
|
||||
verifiedMana: z.boolean(),
|
||||
});
|
||||
|
||||
export function createModerationRoutes(service: ModerationService) {
|
||||
const router = new Hono<{ Variables: { user?: AuthUser } }>();
|
||||
|
||||
// User-facing — anyone authed can file a report.
|
||||
router.post('/reports', async (c) => {
|
||||
const user = requireUser(c.get('user'));
|
||||
const parsed = reportSchema.safeParse(await c.req.json().catch(() => ({})));
|
||||
if (!parsed.success) throw new BadRequestError('Invalid body', parsed.error.format());
|
||||
const row = await service.createReport(user.userId, parsed.data);
|
||||
return c.json(row, 201);
|
||||
});
|
||||
|
||||
// Admin inbox + actions.
|
||||
router.get('/admin/reports', async (c) => {
|
||||
requireAdmin(c.get('user'));
|
||||
const list = await service.listOpen();
|
||||
return c.json(list);
|
||||
});
|
||||
|
||||
router.post('/admin/reports/:id/resolve', async (c) => {
|
||||
const admin = requireAdmin(c.get('user'));
|
||||
const parsed = resolveSchema.safeParse(await c.req.json().catch(() => ({})));
|
||||
if (!parsed.success) throw new BadRequestError('Invalid body', parsed.error.format());
|
||||
const result = await service.resolveReport(admin.userId, c.req.param('id'), parsed.data);
|
||||
return c.json(result);
|
||||
});
|
||||
|
||||
router.post('/admin/decks/:slug/takedown', async (c) => {
|
||||
const admin = requireAdmin(c.get('user'));
|
||||
const parsed = takedownSchema.safeParse(await c.req.json().catch(() => ({})));
|
||||
if (!parsed.success) throw new BadRequestError('Invalid body', parsed.error.format());
|
||||
const result = await service.takedownDeck(
|
||||
admin.userId,
|
||||
c.req.param('slug'),
|
||||
parsed.data.reason
|
||||
);
|
||||
return c.json(result);
|
||||
});
|
||||
|
||||
router.post('/admin/decks/:slug/restore', async (c) => {
|
||||
const admin = requireAdmin(c.get('user'));
|
||||
const result = await service.restoreDeck(admin.userId, c.req.param('slug'));
|
||||
return c.json(result);
|
||||
});
|
||||
|
||||
router.post('/admin/authors/:slug/verify', async (c) => {
|
||||
const admin = requireAdmin(c.get('user'));
|
||||
const parsed = verifySchema.safeParse(await c.req.json().catch(() => ({})));
|
||||
if (!parsed.success) throw new BadRequestError('Invalid body', parsed.error.format());
|
||||
const result = await service.setVerifiedMana(
|
||||
admin.userId,
|
||||
c.req.param('slug'),
|
||||
parsed.data.verifiedMana
|
||||
);
|
||||
return c.json(result);
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
99
docs/marketplace/archive/code/routes/pull-requests.ts
Normal file
99
docs/marketplace/archive/code/routes/pull-requests.ts
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
import { Hono } from 'hono';
|
||||
import { z } from 'zod';
|
||||
import type { AuthUser } from '../middleware/jwt-auth';
|
||||
import type { PullRequestService } from '../services/pull-requests';
|
||||
import { BadRequestError, UnauthorizedError } from '../lib/errors';
|
||||
|
||||
function requireUser(user: AuthUser | undefined): AuthUser {
|
||||
if (!user || !user.userId) throw new UnauthorizedError();
|
||||
return user;
|
||||
}
|
||||
|
||||
const cardTypes = [
|
||||
'basic',
|
||||
'basic-reverse',
|
||||
'cloze',
|
||||
'type-in',
|
||||
'image-occlusion',
|
||||
'audio',
|
||||
'multiple-choice',
|
||||
] as const;
|
||||
|
||||
const cardPayloadSchema = z.object({
|
||||
type: z.enum(cardTypes),
|
||||
fields: z.record(z.string(), z.string()),
|
||||
});
|
||||
|
||||
const createPrSchema = z.object({
|
||||
title: z.string().min(1).max(140),
|
||||
body: z.string().max(4000).optional(),
|
||||
diff: z.object({
|
||||
add: z.array(cardPayloadSchema).default([]),
|
||||
modify: z
|
||||
.array(
|
||||
cardPayloadSchema.extend({
|
||||
previousContentHash: z.string().min(1),
|
||||
})
|
||||
)
|
||||
.default([]),
|
||||
remove: z.array(z.object({ contentHash: z.string().min(1) })).default([]),
|
||||
}),
|
||||
});
|
||||
|
||||
const mergeSchema = z.object({
|
||||
newSemver: z
|
||||
.string()
|
||||
.regex(/^\d+\.\d+\.\d+$/)
|
||||
.optional(),
|
||||
mergeNote: z.string().max(2000).optional(),
|
||||
});
|
||||
|
||||
export function createPullRequestRoutes(service: PullRequestService) {
|
||||
const router = new Hono<{ Variables: { user?: AuthUser } }>();
|
||||
|
||||
router.post('/decks/:slug/pull-requests', async (c) => {
|
||||
const user = requireUser(c.get('user'));
|
||||
const parsed = createPrSchema.safeParse(await c.req.json().catch(() => ({})));
|
||||
if (!parsed.success) throw new BadRequestError('Invalid body', parsed.error.format());
|
||||
const pr = await service.create(user.userId, c.req.param('slug'), parsed.data);
|
||||
return c.json(pr, 201);
|
||||
});
|
||||
|
||||
router.get('/decks/:slug/pull-requests', async (c) => {
|
||||
const url = new URL(c.req.url);
|
||||
const status = url.searchParams.get('status');
|
||||
const valid = ['open', 'merged', 'closed', 'rejected'] as const;
|
||||
const statusFilter = (valid as readonly string[]).includes(status ?? '')
|
||||
? (status as (typeof valid)[number])
|
||||
: undefined;
|
||||
const list = await service.list(c.req.param('slug'), statusFilter);
|
||||
return c.json(list);
|
||||
});
|
||||
|
||||
router.get('/pull-requests/:id', async (c) => {
|
||||
const pr = await service.get(c.req.param('id'));
|
||||
return c.json(pr);
|
||||
});
|
||||
|
||||
router.post('/pull-requests/:id/close', async (c) => {
|
||||
const user = requireUser(c.get('user'));
|
||||
await service.close(user.userId, c.req.param('id'));
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
router.post('/pull-requests/:id/reject', async (c) => {
|
||||
const user = requireUser(c.get('user'));
|
||||
await service.reject(user.userId, c.req.param('id'));
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
router.post('/pull-requests/:id/merge', async (c) => {
|
||||
const user = requireUser(c.get('user'));
|
||||
const parsed = mergeSchema.safeParse(await c.req.json().catch(() => ({})));
|
||||
if (!parsed.success) throw new BadRequestError('Invalid body', parsed.error.format());
|
||||
const result = await service.merge(user.userId, c.req.param('id'), parsed.data);
|
||||
return c.json(result, 201);
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
33
docs/marketplace/archive/code/routes/purchases.ts
Normal file
33
docs/marketplace/archive/code/routes/purchases.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import { Hono } from 'hono';
|
||||
import type { AuthUser } from '../middleware/jwt-auth';
|
||||
import type { PurchaseService } from '../services/purchases';
|
||||
import { UnauthorizedError } from '../lib/errors';
|
||||
|
||||
function requireUser(user: AuthUser | undefined): AuthUser {
|
||||
if (!user || !user.userId) throw new UnauthorizedError();
|
||||
return user;
|
||||
}
|
||||
|
||||
export function createPurchaseRoutes(service: PurchaseService) {
|
||||
const router = new Hono<{ Variables: { user?: AuthUser } }>();
|
||||
|
||||
router.post('/decks/:slug/purchase', async (c) => {
|
||||
const user = requireUser(c.get('user'));
|
||||
const result = await service.purchase(user.userId, c.req.param('slug'));
|
||||
return c.json(result, result.alreadyOwned ? 200 : 201);
|
||||
});
|
||||
|
||||
router.get('/me/purchases', async (c) => {
|
||||
const user = requireUser(c.get('user'));
|
||||
const list = await service.listForBuyer(user.userId);
|
||||
return c.json(list);
|
||||
});
|
||||
|
||||
router.get('/authors/me/payouts', async (c) => {
|
||||
const user = requireUser(c.get('user'));
|
||||
const list = await service.listPayoutsForAuthor(user.userId);
|
||||
return c.json(list);
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
56
docs/marketplace/archive/code/routes/subscriptions.ts
Normal file
56
docs/marketplace/archive/code/routes/subscriptions.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import { Hono } from 'hono';
|
||||
import type { AuthUser } from '../middleware/jwt-auth';
|
||||
import type { SubscriptionService } from '../services/subscriptions';
|
||||
import { BadRequestError, UnauthorizedError } from '../lib/errors';
|
||||
|
||||
function requireUser(user: AuthUser | undefined): AuthUser {
|
||||
if (!user || !user.userId) throw new UnauthorizedError();
|
||||
return user;
|
||||
}
|
||||
|
||||
export function createSubscriptionRoutes(service: SubscriptionService) {
|
||||
const router = new Hono<{ Variables: { user?: AuthUser } }>();
|
||||
|
||||
// User-scoped routes -----------------------------------------------------
|
||||
|
||||
router.get('/me/subscriptions', async (c) => {
|
||||
const user = requireUser(c.get('user'));
|
||||
const list = await service.listForUser(user.userId);
|
||||
return c.json(list);
|
||||
});
|
||||
|
||||
router.post('/decks/:slug/subscribe', async (c) => {
|
||||
const user = requireUser(c.get('user'));
|
||||
const result = await service.subscribe(user.userId, c.req.param('slug'));
|
||||
return c.json(result, 201);
|
||||
});
|
||||
|
||||
router.delete('/decks/:slug/subscribe', async (c) => {
|
||||
const user = requireUser(c.get('user'));
|
||||
await service.unsubscribe(user.userId, c.req.param('slug'));
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
// Public read routes -----------------------------------------------------
|
||||
|
||||
router.get('/decks/:slug/versions/:semver', async (c) => {
|
||||
const semver = c.req.param('semver');
|
||||
if (!/^\d+\.\d+\.\d+$/.test(semver)) {
|
||||
throw new BadRequestError('semver must look like 1.0.0');
|
||||
}
|
||||
const payload = await service.versionWithCards(c.req.param('slug'), semver);
|
||||
return c.json(payload);
|
||||
});
|
||||
|
||||
router.get('/decks/:slug/diff', async (c) => {
|
||||
const url = new URL(c.req.url);
|
||||
const from = url.searchParams.get('from');
|
||||
if (!from || !/^\d+\.\d+\.\d+$/.test(from)) {
|
||||
throw new BadRequestError('?from=<semver> required, e.g. ?from=1.0.0');
|
||||
}
|
||||
const diff = await service.diffSince(c.req.param('slug'), from);
|
||||
return c.json(diff);
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
104
docs/marketplace/archive/code/services/authors.ts
Normal file
104
docs/marketplace/archive/code/services/authors.ts
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
/**
|
||||
* Author service — CRUD on `cards.authors` plus the lookups the
|
||||
* routes need (by-slug, by-userId).
|
||||
*
|
||||
* Slug is unique per author. We don't auto-suggest slugs server-side;
|
||||
* the client picks one and we validate. If a user changes their slug,
|
||||
* the old slug isn't preserved (no redirects yet — Phase η maybe).
|
||||
*/
|
||||
|
||||
import { eq } from 'drizzle-orm';
|
||||
import type { Database } from '../db/connection';
|
||||
import { authors } from '../db/schema';
|
||||
import { validateSlug } from '../lib/slug';
|
||||
import { BadRequestError, ConflictError, NotFoundError } from '../lib/errors';
|
||||
|
||||
export interface AuthorInput {
|
||||
slug: string;
|
||||
displayName: string;
|
||||
bio?: string;
|
||||
avatarUrl?: string;
|
||||
pseudonym?: boolean;
|
||||
}
|
||||
|
||||
export class AuthorService {
|
||||
constructor(private readonly db: Database) {}
|
||||
|
||||
async upsertMe(userId: string, input: AuthorInput) {
|
||||
const validation = validateSlug(input.slug);
|
||||
if (!validation.ok) {
|
||||
throw new BadRequestError(`Slug invalid: ${validation.reason}`);
|
||||
}
|
||||
|
||||
// Slug must be free or already owned by us.
|
||||
const existingBySlug = await this.db.query.authors.findFirst({
|
||||
where: eq(authors.slug, input.slug),
|
||||
});
|
||||
if (existingBySlug && existingBySlug.userId !== userId) {
|
||||
throw new ConflictError('Slug already taken');
|
||||
}
|
||||
|
||||
const existing = await this.db.query.authors.findFirst({
|
||||
where: eq(authors.userId, userId),
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
const [updated] = await this.db
|
||||
.update(authors)
|
||||
.set({
|
||||
slug: input.slug,
|
||||
displayName: input.displayName,
|
||||
bio: input.bio,
|
||||
avatarUrl: input.avatarUrl,
|
||||
pseudonym: input.pseudonym ?? existing.pseudonym,
|
||||
})
|
||||
.where(eq(authors.userId, userId))
|
||||
.returning();
|
||||
return updated;
|
||||
}
|
||||
|
||||
const [created] = await this.db
|
||||
.insert(authors)
|
||||
.values({
|
||||
userId,
|
||||
slug: input.slug,
|
||||
displayName: input.displayName,
|
||||
bio: input.bio,
|
||||
avatarUrl: input.avatarUrl,
|
||||
pseudonym: input.pseudonym ?? false,
|
||||
})
|
||||
.returning();
|
||||
return created;
|
||||
}
|
||||
|
||||
async getByUserId(userId: string) {
|
||||
const row = await this.db.query.authors.findFirst({ where: eq(authors.userId, userId) });
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
/** Public profile lookup — strips bannedReason etc. */
|
||||
async getPublicBySlug(slug: string) {
|
||||
const row = await this.db.query.authors.findFirst({ where: eq(authors.slug, slug) });
|
||||
if (!row) throw new NotFoundError('Author not found');
|
||||
return {
|
||||
slug: row.slug,
|
||||
displayName: row.displayName,
|
||||
bio: row.bio,
|
||||
avatarUrl: row.avatarUrl,
|
||||
joinedAt: row.joinedAt,
|
||||
pseudonym: row.pseudonym,
|
||||
verifiedMana: row.verifiedMana,
|
||||
verifiedCommunity: row.verifiedCommunity,
|
||||
banned: row.bannedAt !== null,
|
||||
};
|
||||
}
|
||||
|
||||
async assertNotBanned(userId: string) {
|
||||
const row = await this.getByUserId(userId);
|
||||
if (!row) throw new BadRequestError('You need an author profile first (POST /v1/authors/me).');
|
||||
if (row.bannedAt) {
|
||||
throw new BadRequestError(`Author banned: ${row.bannedReason ?? 'no reason given'}`);
|
||||
}
|
||||
return row;
|
||||
}
|
||||
}
|
||||
223
docs/marketplace/archive/code/services/decks.ts
Normal file
223
docs/marketplace/archive/code/services/decks.ts
Normal file
|
|
@ -0,0 +1,223 @@
|
|||
/**
|
||||
* Deck service — init + publish.
|
||||
*
|
||||
* `init` claims a slug and creates a `cards.decks` row with no
|
||||
* version yet (so authors can fiddle with metadata before their first
|
||||
* publish). `publish` runs the AI-mod first-pass, computes per-card
|
||||
* + per-version content hashes, writes a new immutable version + its
|
||||
* cards, and atomically updates `latest_version_id` on the deck.
|
||||
*
|
||||
* Per MARKETPLACE_PLAN: a `block` verdict from AI mod refuses the
|
||||
* publish outright. A `flag` verdict still publishes (so the deck
|
||||
* isn't blocked on slow human review) but writes a row into
|
||||
* `ai_moderation_log` so the moderation inbox surfaces it.
|
||||
*/
|
||||
|
||||
import { and, eq, sql } from 'drizzle-orm';
|
||||
import type { Database } from '../db/connection';
|
||||
import { publicDecks, publicDeckVersions, publicDeckCards, aiModerationLog } from '../db/schema';
|
||||
import { validateSlug } from '../lib/slug';
|
||||
import { hashCard, hashVersionCards } from '../lib/hash';
|
||||
import { moderateDeckContent } from '../lib/ai-moderation';
|
||||
import { BadRequestError, ConflictError, ForbiddenError, NotFoundError } from '../lib/errors';
|
||||
|
||||
export interface InitDeckInput {
|
||||
slug: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
language?: string;
|
||||
license?: string;
|
||||
priceCredits?: number;
|
||||
}
|
||||
|
||||
export interface PublishInput {
|
||||
semver: string;
|
||||
changelog?: string;
|
||||
cards: {
|
||||
type:
|
||||
| 'basic'
|
||||
| 'basic-reverse'
|
||||
| 'cloze'
|
||||
| 'type-in'
|
||||
| 'image-occlusion'
|
||||
| 'audio'
|
||||
| 'multiple-choice';
|
||||
fields: Record<string, string>;
|
||||
}[];
|
||||
}
|
||||
|
||||
export interface PublishResult {
|
||||
deck: typeof publicDecks.$inferSelect;
|
||||
version: typeof publicDeckVersions.$inferSelect;
|
||||
moderation: { verdict: 'pass' | 'flag' | 'block'; categories: string[] };
|
||||
}
|
||||
|
||||
const SEMVER_RE = /^(\d+)\.(\d+)\.(\d+)$/;
|
||||
|
||||
function validatePrice(price: number, license: string) {
|
||||
if (price < 0) throw new BadRequestError('priceCredits cannot be negative');
|
||||
if (price > 0 && license !== 'Cardecky-Pro-Only-1.0') {
|
||||
throw new BadRequestError('Paid decks must use the Cardecky-Pro-Only-1.0 license');
|
||||
}
|
||||
}
|
||||
|
||||
export class DeckService {
|
||||
constructor(
|
||||
private readonly db: Database,
|
||||
private readonly llmUrl: string
|
||||
) {}
|
||||
|
||||
async init(ownerUserId: string, input: InitDeckInput) {
|
||||
const validation = validateSlug(input.slug);
|
||||
if (!validation.ok) throw new BadRequestError(`Slug invalid: ${validation.reason}`);
|
||||
|
||||
const license = input.license ?? 'Cardecky-Personal-Use-1.0';
|
||||
const priceCredits = input.priceCredits ?? 0;
|
||||
validatePrice(priceCredits, license);
|
||||
|
||||
const existing = await this.db.query.publicDecks.findFirst({
|
||||
where: eq(publicDecks.slug, input.slug),
|
||||
});
|
||||
if (existing) throw new ConflictError('Slug already taken');
|
||||
|
||||
const [created] = await this.db
|
||||
.insert(publicDecks)
|
||||
.values({
|
||||
slug: input.slug,
|
||||
title: input.title,
|
||||
description: input.description,
|
||||
language: input.language,
|
||||
license,
|
||||
priceCredits,
|
||||
ownerUserId,
|
||||
})
|
||||
.returning();
|
||||
return created;
|
||||
}
|
||||
|
||||
async getBySlug(slug: string) {
|
||||
const deck = await this.db.query.publicDecks.findFirst({
|
||||
where: eq(publicDecks.slug, slug),
|
||||
});
|
||||
if (!deck) throw new NotFoundError('Deck not found');
|
||||
|
||||
const version = deck.latestVersionId
|
||||
? await this.db.query.publicDeckVersions.findFirst({
|
||||
where: eq(publicDeckVersions.id, deck.latestVersionId),
|
||||
})
|
||||
: null;
|
||||
|
||||
return { deck, latestVersion: version };
|
||||
}
|
||||
|
||||
async publish(ownerUserId: string, slug: string, input: PublishInput): Promise<PublishResult> {
|
||||
if (!SEMVER_RE.test(input.semver)) {
|
||||
throw new BadRequestError('semver must look like 1.0.0');
|
||||
}
|
||||
if (input.cards.length === 0) {
|
||||
throw new BadRequestError('A version needs at least one card');
|
||||
}
|
||||
|
||||
const deck = await this.db.query.publicDecks.findFirst({
|
||||
where: eq(publicDecks.slug, slug),
|
||||
});
|
||||
if (!deck) throw new NotFoundError('Deck not found');
|
||||
if (deck.ownerUserId !== ownerUserId) {
|
||||
throw new ForbiddenError('Only the deck owner can publish');
|
||||
}
|
||||
if (deck.isTakedown) throw new ForbiddenError('Deck is under takedown');
|
||||
|
||||
// semver must be strictly greater than the latest published
|
||||
// version so version history stays linear.
|
||||
if (deck.latestVersionId) {
|
||||
const latest = await this.db.query.publicDeckVersions.findFirst({
|
||||
where: eq(publicDeckVersions.id, deck.latestVersionId),
|
||||
});
|
||||
if (latest && !semverGreater(input.semver, latest.semver)) {
|
||||
throw new ConflictError(`semver ${input.semver} must be > ${latest.semver}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 1) AI moderation first-pass.
|
||||
const moderation = await moderateDeckContent(
|
||||
{
|
||||
title: deck.title,
|
||||
description: deck.description ?? undefined,
|
||||
cards: input.cards.map((c) => ({ fields: c.fields })),
|
||||
},
|
||||
this.llmUrl
|
||||
);
|
||||
if (moderation.verdict === 'block') {
|
||||
throw new ForbiddenError(
|
||||
`Refused by content moderation: ${moderation.rationale || 'no rationale'}`
|
||||
);
|
||||
}
|
||||
|
||||
// 2) Compute hashes.
|
||||
const cardsWithOrd = input.cards.map((c, i) => ({ ...c, ord: i }));
|
||||
const versionContentHash = hashVersionCards(cardsWithOrd);
|
||||
|
||||
// 3) Insert version + cards + flip latest_version_id atomically.
|
||||
const result = await this.db.transaction(async (tx) => {
|
||||
const [version] = await tx
|
||||
.insert(publicDeckVersions)
|
||||
.values({
|
||||
deckId: deck.id,
|
||||
semver: input.semver,
|
||||
changelog: input.changelog,
|
||||
contentHash: versionContentHash,
|
||||
cardCount: cardsWithOrd.length,
|
||||
})
|
||||
.returning();
|
||||
|
||||
await tx.insert(publicDeckCards).values(
|
||||
cardsWithOrd.map((c) => ({
|
||||
versionId: version.id,
|
||||
type: c.type,
|
||||
fields: c.fields,
|
||||
ord: c.ord,
|
||||
contentHash: hashCard(c),
|
||||
}))
|
||||
);
|
||||
|
||||
await tx.insert(aiModerationLog).values({
|
||||
versionId: version.id,
|
||||
verdict: moderation.verdict,
|
||||
categories: moderation.categories,
|
||||
model: moderation.model,
|
||||
rationale: moderation.rationale,
|
||||
});
|
||||
|
||||
const [updatedDeck] = await tx
|
||||
.update(publicDecks)
|
||||
.set({ latestVersionId: version.id })
|
||||
.where(and(eq(publicDecks.id, deck.id)))
|
||||
.returning();
|
||||
|
||||
return { deck: updatedDeck, version };
|
||||
});
|
||||
|
||||
return {
|
||||
deck: result.deck,
|
||||
version: result.version,
|
||||
moderation: { verdict: moderation.verdict, categories: moderation.categories },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function semverGreater(a: string, b: string): boolean {
|
||||
const matchA = a.match(SEMVER_RE);
|
||||
const matchB = b.match(SEMVER_RE);
|
||||
if (!matchA || !matchB) return false;
|
||||
for (let i = 1; i <= 3; i++) {
|
||||
const da = Number.parseInt(matchA[i], 10);
|
||||
const db = Number.parseInt(matchB[i], 10);
|
||||
if (da > db) return true;
|
||||
if (da < db) return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Silence unused-binding lint for `sql` import — we keep it ready for
|
||||
// upcoming routes (server-side orderings / counts).
|
||||
void sql;
|
||||
109
docs/marketplace/archive/code/services/discussions.ts
Normal file
109
docs/marketplace/archive/code/services/discussions.ts
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
/**
|
||||
* Card discussions — lightweight inline threads keyed by
|
||||
* `card_content_hash` (not card-id) so a thread survives across
|
||||
* version bumps as long as the card content stays.
|
||||
*
|
||||
* Threads are flat-with-parent: every reply has `parent_id` →
|
||||
* something else in the same `card_content_hash` group. The UI
|
||||
* renders a one-level-deep tree (Reddit-style with a max depth) —
|
||||
* if we want full nesting later it's already there.
|
||||
*/
|
||||
|
||||
import { and, asc, eq, sql } from 'drizzle-orm';
|
||||
import type { Database } from '../db/connection';
|
||||
import { cardDiscussions, publicDecks } from '../db/schema';
|
||||
import { ForbiddenError, NotFoundError } from '../lib/errors';
|
||||
|
||||
export class DiscussionService {
|
||||
constructor(private readonly db: Database) {}
|
||||
|
||||
async post(
|
||||
userId: string,
|
||||
deckSlug: string,
|
||||
cardContentHash: string,
|
||||
body: string,
|
||||
parentId?: string
|
||||
) {
|
||||
const deck = await this.db.query.publicDecks.findFirst({
|
||||
where: eq(publicDecks.slug, deckSlug),
|
||||
});
|
||||
if (!deck) throw new NotFoundError('Deck not found');
|
||||
|
||||
if (parentId) {
|
||||
const parent = await this.db.query.cardDiscussions.findFirst({
|
||||
where: eq(cardDiscussions.id, parentId),
|
||||
});
|
||||
if (!parent) throw new NotFoundError('Parent comment not found');
|
||||
if (parent.cardContentHash !== cardContentHash) {
|
||||
throw new ForbiddenError('Parent comment is on a different card');
|
||||
}
|
||||
}
|
||||
|
||||
const [row] = await this.db
|
||||
.insert(cardDiscussions)
|
||||
.values({
|
||||
cardContentHash,
|
||||
deckId: deck.id,
|
||||
authorUserId: userId,
|
||||
parentId: parentId ?? null,
|
||||
body,
|
||||
})
|
||||
.returning();
|
||||
return row;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk count of (visible) comments per card-content-hash for one
|
||||
* deck — powers the "Karten" overview on the public deck page so
|
||||
* we don't fan out one request per card.
|
||||
*/
|
||||
async countsForDeck(deckSlug: string): Promise<Record<string, number>> {
|
||||
const deck = await this.db.query.publicDecks.findFirst({
|
||||
where: eq(publicDecks.slug, deckSlug),
|
||||
});
|
||||
if (!deck) throw new NotFoundError('Deck not found');
|
||||
|
||||
const rows = await this.db
|
||||
.select({
|
||||
contentHash: cardDiscussions.cardContentHash,
|
||||
count: sql<number>`count(*)::int`.as('count'),
|
||||
})
|
||||
.from(cardDiscussions)
|
||||
.where(and(eq(cardDiscussions.deckId, deck.id), eq(cardDiscussions.hidden, false)))
|
||||
.groupBy(cardDiscussions.cardContentHash);
|
||||
|
||||
const out: Record<string, number> = {};
|
||||
for (const r of rows) out[r.contentHash] = r.count;
|
||||
return out;
|
||||
}
|
||||
|
||||
async listForCard(cardContentHash: string) {
|
||||
const rows = await this.db
|
||||
.select()
|
||||
.from(cardDiscussions)
|
||||
.where(
|
||||
and(eq(cardDiscussions.cardContentHash, cardContentHash), eq(cardDiscussions.hidden, false))
|
||||
)
|
||||
.orderBy(asc(cardDiscussions.createdAt));
|
||||
return rows;
|
||||
}
|
||||
|
||||
async hide(actorUserId: string, discussionId: string) {
|
||||
const row = await this.db.query.cardDiscussions.findFirst({
|
||||
where: eq(cardDiscussions.id, discussionId),
|
||||
});
|
||||
if (!row) throw new NotFoundError('Discussion not found');
|
||||
const deck = await this.db.query.publicDecks.findFirst({
|
||||
where: eq(publicDecks.id, row.deckId),
|
||||
});
|
||||
if (!deck) throw new NotFoundError('Deck not found');
|
||||
// Author of the comment OR deck owner can hide.
|
||||
if (row.authorUserId !== actorUserId && deck.ownerUserId !== actorUserId) {
|
||||
throw new ForbiddenError('Not allowed to hide this comment');
|
||||
}
|
||||
await this.db
|
||||
.update(cardDiscussions)
|
||||
.set({ hidden: true })
|
||||
.where(eq(cardDiscussions.id, discussionId));
|
||||
}
|
||||
}
|
||||
79
docs/marketplace/archive/code/services/engagement.ts
Normal file
79
docs/marketplace/archive/code/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
docs/marketplace/archive/code/services/explore.ts
Normal file
195
docs/marketplace/archive/code/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;
|
||||
}
|
||||
}
|
||||
280
docs/marketplace/archive/code/services/moderation.ts
Normal file
280
docs/marketplace/archive/code/services/moderation.ts
Normal file
|
|
@ -0,0 +1,280 @@
|
|||
/**
|
||||
* Phase η.1 — User-submitted reports + admin actions.
|
||||
*
|
||||
* Anyone authed can file a report against a deck (optionally scoped
|
||||
* to one card via `cardContentHash`). Admins (`role === 'admin'`)
|
||||
* pull the open inbox, dismiss false positives, take a deck down, or
|
||||
* ban an author. The inbox auto-resolves all open reports for a deck
|
||||
* when a takedown lands so admins don't have to chase duplicates.
|
||||
*/
|
||||
|
||||
import { and, desc, eq, isNull } from 'drizzle-orm';
|
||||
import type { Database } from '../db/connection';
|
||||
import {
|
||||
authors,
|
||||
deckPullRequests,
|
||||
deckReports,
|
||||
publicDecks,
|
||||
type reportCategoryEnum,
|
||||
} from '../db/schema';
|
||||
import { BadRequestError, ForbiddenError, NotFoundError } from '../lib/errors';
|
||||
import type { NotifyClient } from '../lib/notify';
|
||||
|
||||
type ReportCategory = (typeof reportCategoryEnum.enumValues)[number];
|
||||
|
||||
export interface CreateReportInput {
|
||||
deckSlug: string;
|
||||
cardContentHash?: string;
|
||||
category: ReportCategory;
|
||||
body?: string;
|
||||
}
|
||||
|
||||
export interface ResolveReportInput {
|
||||
action: 'dismiss' | 'takedown' | 'ban-author';
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
const VALID_CATEGORIES = new Set<ReportCategory>([
|
||||
'spam',
|
||||
'copyright',
|
||||
'nsfw',
|
||||
'misinformation',
|
||||
'hate',
|
||||
'other',
|
||||
]);
|
||||
|
||||
export class ModerationService {
|
||||
constructor(
|
||||
private readonly db: Database,
|
||||
private readonly notify?: NotifyClient
|
||||
) {}
|
||||
|
||||
async createReport(reporterUserId: string, input: CreateReportInput) {
|
||||
if (!VALID_CATEGORIES.has(input.category)) {
|
||||
throw new BadRequestError(`Unknown report category: ${input.category}`);
|
||||
}
|
||||
const deck = await this.db.query.publicDecks.findFirst({
|
||||
where: eq(publicDecks.slug, input.deckSlug),
|
||||
});
|
||||
if (!deck) throw new NotFoundError('Deck not found');
|
||||
|
||||
const [row] = await this.db
|
||||
.insert(deckReports)
|
||||
.values({
|
||||
deckId: deck.id,
|
||||
versionId: deck.latestVersionId ?? null,
|
||||
cardContentHash: input.cardContentHash ?? null,
|
||||
reporterUserId,
|
||||
category: input.category,
|
||||
body: input.body ?? null,
|
||||
})
|
||||
.returning();
|
||||
return row;
|
||||
}
|
||||
|
||||
async listOpen(limit = 50) {
|
||||
return this.db
|
||||
.select({
|
||||
id: deckReports.id,
|
||||
deckId: deckReports.deckId,
|
||||
deckSlug: publicDecks.slug,
|
||||
deckTitle: publicDecks.title,
|
||||
cardContentHash: deckReports.cardContentHash,
|
||||
reporterUserId: deckReports.reporterUserId,
|
||||
category: deckReports.category,
|
||||
body: deckReports.body,
|
||||
status: deckReports.status,
|
||||
createdAt: deckReports.createdAt,
|
||||
})
|
||||
.from(deckReports)
|
||||
.innerJoin(publicDecks, eq(deckReports.deckId, publicDecks.id))
|
||||
.where(eq(deckReports.status, 'open'))
|
||||
.orderBy(desc(deckReports.createdAt))
|
||||
.limit(limit);
|
||||
}
|
||||
|
||||
async resolveReport(adminUserId: string, reportId: string, input: ResolveReportInput) {
|
||||
const report = await this.db.query.deckReports.findFirst({
|
||||
where: eq(deckReports.id, reportId),
|
||||
});
|
||||
if (!report) throw new NotFoundError('Report not found');
|
||||
if (report.status !== 'open') {
|
||||
throw new BadRequestError(`Report already ${report.status}`);
|
||||
}
|
||||
const deck = await this.db.query.publicDecks.findFirst({
|
||||
where: eq(publicDecks.id, report.deckId),
|
||||
});
|
||||
if (!deck) throw new NotFoundError('Deck disappeared');
|
||||
|
||||
if (input.action === 'dismiss') {
|
||||
await this.markResolved(reportId, adminUserId, 'dismissed', input.notes);
|
||||
return { action: 'dismissed' as const };
|
||||
}
|
||||
|
||||
if (input.action === 'takedown') {
|
||||
await this.takedownDeck(adminUserId, deck.slug, input.notes);
|
||||
await this.markResolved(reportId, adminUserId, 'actioned', input.notes);
|
||||
return { action: 'takedown' as const };
|
||||
}
|
||||
|
||||
if (input.action === 'ban-author') {
|
||||
await this.banAuthor(adminUserId, deck.ownerUserId, input.notes);
|
||||
// A banned author's decks get taken down too — saves a click.
|
||||
await this.takedownDeck(adminUserId, deck.slug, input.notes ?? 'Author banned');
|
||||
await this.markResolved(reportId, adminUserId, 'actioned', input.notes);
|
||||
return { action: 'ban-author' as const };
|
||||
}
|
||||
|
||||
throw new BadRequestError(`Unknown action: ${input.action as string}`);
|
||||
}
|
||||
|
||||
private async markResolved(
|
||||
reportId: string,
|
||||
adminUserId: string,
|
||||
status: 'dismissed' | 'actioned',
|
||||
notes: string | undefined
|
||||
) {
|
||||
await this.db
|
||||
.update(deckReports)
|
||||
.set({
|
||||
status,
|
||||
resolvedBy: adminUserId,
|
||||
resolvedAt: new Date(),
|
||||
resolutionNotes: notes ?? null,
|
||||
})
|
||||
.where(eq(deckReports.id, reportId));
|
||||
}
|
||||
|
||||
async takedownDeck(adminUserId: string, deckSlug: string, reason?: string) {
|
||||
const deck = await this.db.query.publicDecks.findFirst({
|
||||
where: eq(publicDecks.slug, deckSlug),
|
||||
});
|
||||
if (!deck) throw new NotFoundError('Deck not found');
|
||||
if (deck.isTakedown) return { alreadyDown: true };
|
||||
|
||||
await this.db.transaction(async (tx) => {
|
||||
await tx
|
||||
.update(publicDecks)
|
||||
.set({
|
||||
isTakedown: true,
|
||||
takedownAt: new Date(),
|
||||
takedownReason: reason ?? 'Moderation action',
|
||||
})
|
||||
.where(eq(publicDecks.id, deck.id));
|
||||
|
||||
// Auto-close any other open reports against the same deck.
|
||||
await tx
|
||||
.update(deckReports)
|
||||
.set({
|
||||
status: 'actioned',
|
||||
resolvedBy: adminUserId,
|
||||
resolvedAt: new Date(),
|
||||
resolutionNotes: 'Auto-closed by takedown',
|
||||
})
|
||||
.where(and(eq(deckReports.deckId, deck.id), eq(deckReports.status, 'open')));
|
||||
|
||||
// Open PRs against the deck are no longer mergeable; mark them
|
||||
// closed so authors / contributors see clear state.
|
||||
await tx
|
||||
.update(deckPullRequests)
|
||||
.set({
|
||||
status: 'closed',
|
||||
resolvedAt: new Date(),
|
||||
})
|
||||
.where(and(eq(deckPullRequests.deckId, deck.id), eq(deckPullRequests.status, 'open')));
|
||||
});
|
||||
|
||||
if (this.notify) {
|
||||
void this.notify.send({
|
||||
channel: 'email',
|
||||
userId: deck.ownerUserId,
|
||||
subject: `Dein Deck „${deck.title}" wurde entfernt`,
|
||||
body: `Dein Deck „${deck.title}" wurde von der Moderation entfernt.${
|
||||
reason ? `\n\nGrund: ${reason}` : ''
|
||||
}\n\nDu hast 30 Tage Zeit, gegen die Entscheidung Einspruch einzulegen.`,
|
||||
data: {
|
||||
type: 'cards.deck.takedown',
|
||||
deckSlug: deck.slug,
|
||||
reason: reason ?? null,
|
||||
},
|
||||
externalId: `cards.deck.takedown.${deck.id}`,
|
||||
});
|
||||
}
|
||||
|
||||
return { alreadyDown: false };
|
||||
}
|
||||
|
||||
async banAuthor(adminUserId: string, targetUserId: string, reason?: string) {
|
||||
const author = await this.db.query.authors.findFirst({
|
||||
where: eq(authors.userId, targetUserId),
|
||||
});
|
||||
if (!author) throw new NotFoundError('Author not found');
|
||||
if (author.bannedAt) return { alreadyBanned: true };
|
||||
|
||||
await this.db
|
||||
.update(authors)
|
||||
.set({ bannedAt: new Date() })
|
||||
.where(eq(authors.userId, targetUserId));
|
||||
|
||||
// Take down every deck owned by the banned author.
|
||||
const banned = await this.db
|
||||
.select({ slug: publicDecks.slug })
|
||||
.from(publicDecks)
|
||||
.where(and(eq(publicDecks.ownerUserId, targetUserId), eq(publicDecks.isTakedown, false)));
|
||||
for (const d of banned) {
|
||||
await this.takedownDeck(adminUserId, d.slug, reason ?? 'Author banned');
|
||||
}
|
||||
|
||||
return { alreadyBanned: false };
|
||||
}
|
||||
|
||||
async setVerifiedMana(adminUserId: string, authorSlug: string, verified: boolean) {
|
||||
void adminUserId;
|
||||
const author = await this.db.query.authors.findFirst({
|
||||
where: eq(authors.slug, authorSlug),
|
||||
});
|
||||
if (!author) throw new NotFoundError('Author not found');
|
||||
await this.db
|
||||
.update(authors)
|
||||
.set({ verifiedMana: verified })
|
||||
.where(eq(authors.userId, author.userId));
|
||||
|
||||
if (this.notify) {
|
||||
void this.notify.send({
|
||||
channel: 'email',
|
||||
userId: author.userId,
|
||||
subject: verified ? '🛡️ Du bist jetzt Mana-Verifiziert' : 'Mana-Verifizierung entzogen',
|
||||
body: verified
|
||||
? 'Mana-e.V. hat dich als verifizierten Author bestätigt. Dein Author-Cut steigt von 80% auf 90%.'
|
||||
: 'Deine Mana-Verifizierung wurde entzogen. Bei Fragen: kontakt@mana.how.',
|
||||
data: {
|
||||
type: 'cards.author.verified',
|
||||
authorSlug,
|
||||
verified,
|
||||
},
|
||||
externalId: `cards.author.verified.${author.userId}.${verified ? '1' : '0'}.${Date.now()}`,
|
||||
});
|
||||
}
|
||||
|
||||
return { authorSlug, verifiedMana: verified };
|
||||
}
|
||||
|
||||
/**
|
||||
* Lift a takedown — used during appeals. Reports stay closed.
|
||||
*/
|
||||
async restoreDeck(adminUserId: string, deckSlug: string) {
|
||||
void adminUserId;
|
||||
const deck = await this.db.query.publicDecks.findFirst({
|
||||
where: eq(publicDecks.slug, deckSlug),
|
||||
});
|
||||
if (!deck) throw new NotFoundError('Deck not found');
|
||||
if (!deck.isTakedown) throw new BadRequestError('Deck is not under takedown');
|
||||
|
||||
await this.db
|
||||
.update(publicDecks)
|
||||
.set({ isTakedown: false, takedownAt: null, takedownReason: null })
|
||||
.where(eq(publicDecks.id, deck.id));
|
||||
void isNull;
|
||||
return { restored: true };
|
||||
}
|
||||
}
|
||||
318
docs/marketplace/archive/code/services/pull-requests.ts
Normal file
318
docs/marketplace/archive/code/services/pull-requests.ts
Normal file
|
|
@ -0,0 +1,318 @@
|
|||
/**
|
||||
* Pull-requests on decks. The differentiator vs. Anki/Quizlet/etc.:
|
||||
* subscribers can submit a card-level patch, the deck author reviews
|
||||
* + merges, and the merge auto-creates a new version that ripples
|
||||
* through every other subscriber's smart-merge.
|
||||
*
|
||||
* The diff payload mirrors GitHub's three-way model in the small:
|
||||
* - add: cards to insert (server picks the next ord)
|
||||
* - modify: replace existing cards by previous-content-hash
|
||||
* - remove: drop cards by content-hash
|
||||
*
|
||||
* Status lifecycle:
|
||||
* open ──merge──► merged (creates a new deck_version)
|
||||
* open ──close──► closed (author OR PR-author can close)
|
||||
* open ──reject─► rejected (author-only — distinct from "closed"
|
||||
* so the PR-author sees clear feedback)
|
||||
*
|
||||
* Merging bumps the deck's semver minor by default (1.2.0 → 1.3.0)
|
||||
* unless the request specifies otherwise. Author can override at
|
||||
* merge-time.
|
||||
*/
|
||||
|
||||
import { and, desc, eq } from 'drizzle-orm';
|
||||
import type { Database } from '../db/connection';
|
||||
import { deckPullRequests, publicDeckCards, publicDeckVersions, publicDecks } from '../db/schema';
|
||||
import { hashCard, hashVersionCards } from '../lib/hash';
|
||||
import { BadRequestError, ForbiddenError, NotFoundError } from '../lib/errors';
|
||||
import type { NotifyClient } from '../lib/notify';
|
||||
|
||||
export interface PullRequestDiffInput {
|
||||
add: { type: string; fields: Record<string, string> }[];
|
||||
modify: { previousContentHash: string; type: string; fields: Record<string, string> }[];
|
||||
remove: { contentHash: string }[];
|
||||
}
|
||||
|
||||
export interface CreatePullRequestInput {
|
||||
title: string;
|
||||
body?: string;
|
||||
diff: PullRequestDiffInput;
|
||||
}
|
||||
|
||||
const SEMVER_RE = /^(\d+)\.(\d+)\.(\d+)$/;
|
||||
|
||||
function bumpMinor(semver: string): string {
|
||||
const m = semver.match(SEMVER_RE);
|
||||
if (!m) return '1.0.0';
|
||||
return `${m[1]}.${Number(m[2]) + 1}.0`;
|
||||
}
|
||||
|
||||
export class PullRequestService {
|
||||
constructor(
|
||||
private readonly db: Database,
|
||||
private readonly notify?: NotifyClient
|
||||
) {}
|
||||
|
||||
async create(authorUserId: string, deckSlug: string, input: CreatePullRequestInput) {
|
||||
const deck = await this.db.query.publicDecks.findFirst({
|
||||
where: eq(publicDecks.slug, deckSlug),
|
||||
});
|
||||
if (!deck) throw new NotFoundError('Deck not found');
|
||||
if (deck.isTakedown) throw new ForbiddenError('Deck under takedown');
|
||||
|
||||
const total = input.diff.add.length + input.diff.modify.length + input.diff.remove.length;
|
||||
if (total === 0) throw new BadRequestError('Diff is empty');
|
||||
|
||||
const [pr] = await this.db
|
||||
.insert(deckPullRequests)
|
||||
.values({
|
||||
deckId: deck.id,
|
||||
authorUserId,
|
||||
title: input.title,
|
||||
body: input.body,
|
||||
status: 'open',
|
||||
diff: {
|
||||
add: input.diff.add,
|
||||
modify: input.diff.modify.map((m) => ({
|
||||
contentHash: m.previousContentHash,
|
||||
fields: m.fields,
|
||||
})),
|
||||
remove: input.diff.remove,
|
||||
},
|
||||
})
|
||||
.returning();
|
||||
|
||||
// Don't notify on self-PRs (author proposing a change to their own deck).
|
||||
if (this.notify && deck.ownerUserId !== authorUserId) {
|
||||
void this.notify.send({
|
||||
channel: 'email',
|
||||
userId: deck.ownerUserId,
|
||||
subject: `Neuer Pull Request für „${deck.title}"`,
|
||||
body: `Du hast einen neuen Pull Request bekommen: „${input.title}"\n\nÖffne ${this.deckUrl(deckSlug)}, um zu reviewen.`,
|
||||
data: {
|
||||
type: 'cards.pr.created',
|
||||
deckSlug,
|
||||
prId: pr.id,
|
||||
url: this.deckUrl(deckSlug),
|
||||
},
|
||||
externalId: `cards.pr.created.${pr.id}`,
|
||||
});
|
||||
}
|
||||
|
||||
return pr;
|
||||
}
|
||||
|
||||
private deckUrl(slug: string): string {
|
||||
const base = process.env.CARDS_WEB_URL || 'https://cardecky.mana.how';
|
||||
return `${base}/d/${slug}`;
|
||||
}
|
||||
|
||||
async list(deckSlug: string, status?: 'open' | 'merged' | 'closed' | 'rejected') {
|
||||
const deck = await this.db.query.publicDecks.findFirst({
|
||||
where: eq(publicDecks.slug, deckSlug),
|
||||
});
|
||||
if (!deck) throw new NotFoundError('Deck not found');
|
||||
|
||||
const where = status
|
||||
? and(eq(deckPullRequests.deckId, deck.id), eq(deckPullRequests.status, status))
|
||||
: eq(deckPullRequests.deckId, deck.id);
|
||||
return this.db
|
||||
.select()
|
||||
.from(deckPullRequests)
|
||||
.where(where)
|
||||
.orderBy(desc(deckPullRequests.createdAt));
|
||||
}
|
||||
|
||||
async get(prId: string) {
|
||||
const pr = await this.db.query.deckPullRequests.findFirst({
|
||||
where: eq(deckPullRequests.id, prId),
|
||||
});
|
||||
if (!pr) throw new NotFoundError('Pull request not found');
|
||||
return pr;
|
||||
}
|
||||
|
||||
async close(actorUserId: string, prId: string): Promise<void> {
|
||||
const pr = await this.get(prId);
|
||||
const deck = await this.db.query.publicDecks.findFirst({
|
||||
where: eq(publicDecks.id, pr.deckId),
|
||||
});
|
||||
if (!deck) throw new NotFoundError('Deck not found');
|
||||
// Either the deck owner or the PR author can close.
|
||||
if (pr.authorUserId !== actorUserId && deck.ownerUserId !== actorUserId) {
|
||||
throw new ForbiddenError('Only PR author or deck owner can close');
|
||||
}
|
||||
if (pr.status !== 'open') throw new BadRequestError(`PR already ${pr.status}`);
|
||||
await this.db
|
||||
.update(deckPullRequests)
|
||||
.set({ status: 'closed', resolvedAt: new Date() })
|
||||
.where(eq(deckPullRequests.id, prId));
|
||||
}
|
||||
|
||||
async reject(actorUserId: string, prId: string): Promise<void> {
|
||||
const pr = await this.get(prId);
|
||||
const deck = await this.db.query.publicDecks.findFirst({
|
||||
where: eq(publicDecks.id, pr.deckId),
|
||||
});
|
||||
if (!deck) throw new NotFoundError('Deck not found');
|
||||
if (deck.ownerUserId !== actorUserId) {
|
||||
throw new ForbiddenError('Only the deck owner can reject');
|
||||
}
|
||||
if (pr.status !== 'open') throw new BadRequestError(`PR already ${pr.status}`);
|
||||
await this.db
|
||||
.update(deckPullRequests)
|
||||
.set({ status: 'rejected', resolvedAt: new Date() })
|
||||
.where(eq(deckPullRequests.id, prId));
|
||||
|
||||
if (this.notify && pr.authorUserId !== actorUserId) {
|
||||
void this.notify.send({
|
||||
channel: 'email',
|
||||
userId: pr.authorUserId,
|
||||
subject: `Pull Request „${pr.title}" abgelehnt`,
|
||||
body: `Dein Pull Request für „${deck.title}" wurde abgelehnt. Siehe ${this.deckUrl(deck.slug)}.`,
|
||||
data: { type: 'cards.pr.rejected', prId: pr.id, deckSlug: deck.slug },
|
||||
externalId: `cards.pr.rejected.${pr.id}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge a PR. Builds a brand-new version's card list by applying
|
||||
* the PR's diff to the deck's latest version, then writes the
|
||||
* usual version + cards rows and bumps `latest_version_id`.
|
||||
*
|
||||
* The merge happens in a single transaction so a partial failure
|
||||
* doesn't leave the deck pointing at an empty version.
|
||||
*/
|
||||
async merge(
|
||||
actorUserId: string,
|
||||
prId: string,
|
||||
opts: { newSemver?: string; mergeNote?: string } = {}
|
||||
) {
|
||||
const pr = await this.get(prId);
|
||||
if (pr.status !== 'open') throw new BadRequestError(`PR already ${pr.status}`);
|
||||
|
||||
const deck = await this.db.query.publicDecks.findFirst({
|
||||
where: eq(publicDecks.id, pr.deckId),
|
||||
});
|
||||
if (!deck) throw new NotFoundError('Deck not found');
|
||||
if (deck.ownerUserId !== actorUserId) {
|
||||
throw new ForbiddenError('Only the deck owner can merge');
|
||||
}
|
||||
if (!deck.latestVersionId) {
|
||||
throw new BadRequestError('Deck has no published version yet — publish first');
|
||||
}
|
||||
const latest = await this.db.query.publicDeckVersions.findFirst({
|
||||
where: eq(publicDeckVersions.id, deck.latestVersionId),
|
||||
});
|
||||
if (!latest) throw new NotFoundError('Latest version row missing');
|
||||
|
||||
const newSemver = opts.newSemver ?? bumpMinor(latest.semver);
|
||||
if (!SEMVER_RE.test(newSemver)) {
|
||||
throw new BadRequestError(`Invalid semver: ${newSemver}`);
|
||||
}
|
||||
|
||||
// Pull current cards as the base for the merge.
|
||||
const currentCards = await this.db
|
||||
.select()
|
||||
.from(publicDeckCards)
|
||||
.where(eq(publicDeckCards.versionId, latest.id))
|
||||
.orderBy(publicDeckCards.ord);
|
||||
|
||||
const diff = pr.diff as {
|
||||
add: { type: string; fields: Record<string, string> }[];
|
||||
modify: { contentHash: string; fields: Record<string, string> }[];
|
||||
remove: { contentHash: string }[];
|
||||
};
|
||||
|
||||
const removedHashes = new Set(diff.remove.map((r) => r.contentHash));
|
||||
const modifyByHash = new Map(diff.modify.map((m) => [m.contentHash, m.fields]));
|
||||
|
||||
const merged: { type: string; fields: Record<string, string>; ord: number }[] = [];
|
||||
let nextOrd = 0;
|
||||
for (const c of currentCards) {
|
||||
if (removedHashes.has(c.contentHash)) continue;
|
||||
const replaced = modifyByHash.get(c.contentHash);
|
||||
merged.push({
|
||||
type: c.type,
|
||||
fields: replaced ?? (c.fields as Record<string, string>),
|
||||
ord: nextOrd++,
|
||||
});
|
||||
}
|
||||
for (const a of diff.add) {
|
||||
merged.push({ type: a.type, fields: a.fields, ord: nextOrd++ });
|
||||
}
|
||||
|
||||
if (merged.length === 0) {
|
||||
throw new BadRequestError('Merge would result in an empty deck — refusing');
|
||||
}
|
||||
|
||||
const versionContentHash = hashVersionCards(merged);
|
||||
|
||||
const result = await this.db.transaction(async (tx) => {
|
||||
const [version] = await tx
|
||||
.insert(publicDeckVersions)
|
||||
.values({
|
||||
deckId: deck.id,
|
||||
semver: newSemver,
|
||||
changelog:
|
||||
opts.mergeNote ??
|
||||
`Merged PR: ${pr.title} (+${diff.add.length} added, ~${diff.modify.length} modified, −${diff.remove.length} removed)`,
|
||||
contentHash: versionContentHash,
|
||||
cardCount: merged.length,
|
||||
})
|
||||
.returning();
|
||||
|
||||
await tx.insert(publicDeckCards).values(
|
||||
merged.map((c) => ({
|
||||
versionId: version.id,
|
||||
type: c.type as
|
||||
| 'basic'
|
||||
| 'basic-reverse'
|
||||
| 'cloze'
|
||||
| 'type-in'
|
||||
| 'image-occlusion'
|
||||
| 'audio'
|
||||
| 'multiple-choice',
|
||||
fields: c.fields,
|
||||
ord: c.ord,
|
||||
contentHash: hashCard({ type: c.type, fields: c.fields }),
|
||||
}))
|
||||
);
|
||||
|
||||
await tx
|
||||
.update(publicDecks)
|
||||
.set({ latestVersionId: version.id })
|
||||
.where(eq(publicDecks.id, deck.id));
|
||||
|
||||
await tx
|
||||
.update(deckPullRequests)
|
||||
.set({
|
||||
status: 'merged',
|
||||
mergedIntoVersionId: version.id,
|
||||
resolvedAt: new Date(),
|
||||
})
|
||||
.where(eq(deckPullRequests.id, prId));
|
||||
|
||||
return { version };
|
||||
});
|
||||
|
||||
if (this.notify && pr.authorUserId !== actorUserId) {
|
||||
void this.notify.send({
|
||||
channel: 'email',
|
||||
userId: pr.authorUserId,
|
||||
subject: `Pull Request „${pr.title}" gemerged`,
|
||||
body: `Dein Pull Request für „${deck.title}" ist live in v${newSemver}. Danke für den Beitrag!`,
|
||||
data: {
|
||||
type: 'cards.pr.merged',
|
||||
prId: pr.id,
|
||||
deckSlug: deck.slug,
|
||||
newSemver,
|
||||
url: this.deckUrl(deck.slug),
|
||||
},
|
||||
externalId: `cards.pr.merged.${pr.id}`,
|
||||
});
|
||||
}
|
||||
|
||||
return { pullRequest: { ...pr, status: 'merged' as const }, version: result.version };
|
||||
}
|
||||
}
|
||||
233
docs/marketplace/archive/code/services/purchases.ts
Normal file
233
docs/marketplace/archive/code/services/purchases.ts
Normal file
|
|
@ -0,0 +1,233 @@
|
|||
/**
|
||||
* Paid-deck purchase pipeline. Phase ζ.1 — buyer pays, author gets
|
||||
* the configured share, Mana keeps the rest. Lifetime access per
|
||||
* (buyer, deck) — same row covers all future versions of the deck.
|
||||
*
|
||||
* The flow is two-phase against mana-credits:
|
||||
*
|
||||
* 1. reserve(buyer, price) — atomic balance check + hold
|
||||
* 2. INSERT deck_purchases row
|
||||
* 3. commit(reservationId) — finalise the buyer-side debit
|
||||
* 4. grant(author, authorShare) — author payout
|
||||
* 5. INSERT author_payouts row
|
||||
*
|
||||
* If step 3 or 4 fails after the purchase row exists, we leave the
|
||||
* row alone (idempotency relies on the unique (buyer, deck) index).
|
||||
* A future reconciler can sweep purchase rows whose
|
||||
* `creditsTransaction` is null and either commit-retry or roll back
|
||||
* via a manual refund.
|
||||
*/
|
||||
|
||||
import { and, desc, eq } from 'drizzle-orm';
|
||||
import type { Database } from '../db/connection';
|
||||
import {
|
||||
authorPayouts,
|
||||
authors,
|
||||
deckPurchases,
|
||||
publicDecks,
|
||||
publicDeckVersions,
|
||||
} from '../db/schema';
|
||||
import { BadRequestError, ForbiddenError, NotFoundError } from '../lib/errors';
|
||||
import type { CreditsClient } from '../lib/credits';
|
||||
import { InsufficientCreditsError } from '../lib/credits';
|
||||
import type { NotifyClient } from '../lib/notify';
|
||||
|
||||
interface PurchaseConfig {
|
||||
standardAuthorBps: number;
|
||||
verifiedAuthorBps: number;
|
||||
}
|
||||
|
||||
export class PurchaseService {
|
||||
constructor(
|
||||
private readonly db: Database,
|
||||
private readonly credits: CreditsClient,
|
||||
private readonly config: PurchaseConfig,
|
||||
private readonly notify?: NotifyClient
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Idempotent: if the buyer already owns the deck, returns the
|
||||
* existing purchase row without touching mana-credits.
|
||||
*/
|
||||
async purchase(buyerUserId: string, deckSlug: string) {
|
||||
const deck = await this.db.query.publicDecks.findFirst({
|
||||
where: eq(publicDecks.slug, deckSlug),
|
||||
});
|
||||
if (!deck) throw new NotFoundError('Deck not found');
|
||||
if (deck.isTakedown) throw new ForbiddenError('Deck under takedown');
|
||||
if (deck.priceCredits <= 0) {
|
||||
throw new BadRequestError('Deck is free — no purchase required');
|
||||
}
|
||||
if (deck.ownerUserId === buyerUserId) {
|
||||
throw new BadRequestError('Cannot purchase your own deck');
|
||||
}
|
||||
if (!deck.latestVersionId) {
|
||||
throw new BadRequestError('Deck has no published version');
|
||||
}
|
||||
|
||||
// Idempotency.
|
||||
const existing = await this.db.query.deckPurchases.findFirst({
|
||||
where: and(eq(deckPurchases.buyerUserId, buyerUserId), eq(deckPurchases.deckId, deck.id)),
|
||||
});
|
||||
if (existing) {
|
||||
if (existing.refundedAt) {
|
||||
throw new BadRequestError('Purchase was previously refunded');
|
||||
}
|
||||
return { purchase: existing, alreadyOwned: true };
|
||||
}
|
||||
|
||||
const author = await this.db.query.authors.findFirst({
|
||||
where: eq(authors.userId, deck.ownerUserId),
|
||||
});
|
||||
if (!author) throw new NotFoundError('Author profile missing');
|
||||
|
||||
// Author share split — verified-mana authors get a higher cut.
|
||||
const authorBps = author.verifiedMana
|
||||
? this.config.verifiedAuthorBps
|
||||
: this.config.standardAuthorBps;
|
||||
const authorShare = Math.floor((deck.priceCredits * authorBps) / 10_000);
|
||||
const manaShare = deck.priceCredits - authorShare;
|
||||
|
||||
// Step 1 — reserve.
|
||||
let reservationId: string;
|
||||
try {
|
||||
const reservation = await this.credits.reserve({
|
||||
userId: buyerUserId,
|
||||
amount: deck.priceCredits,
|
||||
reason: `cards.deck-purchase:${deck.slug}`,
|
||||
});
|
||||
reservationId = reservation.reservationId;
|
||||
} catch (e) {
|
||||
if (e instanceof InsufficientCreditsError) throw e;
|
||||
throw e;
|
||||
}
|
||||
|
||||
// Step 2 — write the purchase row.
|
||||
let purchase: typeof deckPurchases.$inferSelect;
|
||||
try {
|
||||
[purchase] = await this.db
|
||||
.insert(deckPurchases)
|
||||
.values({
|
||||
buyerUserId,
|
||||
deckId: deck.id,
|
||||
versionId: deck.latestVersionId,
|
||||
priceCredits: deck.priceCredits,
|
||||
authorShare,
|
||||
manaShare,
|
||||
})
|
||||
.returning();
|
||||
} catch (insertErr) {
|
||||
// Rollback the reservation so the buyer's credits aren't held.
|
||||
await this.credits
|
||||
.refundReservation({ reservationId })
|
||||
.catch((refundErr) =>
|
||||
console.warn('[purchases] reservation refund after insert-fail failed', refundErr)
|
||||
);
|
||||
throw insertErr;
|
||||
}
|
||||
|
||||
// Step 3 — commit the buyer-side debit.
|
||||
try {
|
||||
await this.credits.commit({
|
||||
reservationId,
|
||||
description: `Cards: ${deck.title} (${deck.slug})`,
|
||||
});
|
||||
} catch (commitErr) {
|
||||
console.warn('[purchases] commit failed — purchase row remains for reconciler', commitErr);
|
||||
throw commitErr;
|
||||
}
|
||||
|
||||
// Step 4 — grant the author share. Failures here don't affect
|
||||
// the buyer's access (they already paid + got the row); we log
|
||||
// and rely on the reconciler to retry the grant.
|
||||
let payoutRow: typeof authorPayouts.$inferSelect | null = null;
|
||||
if (authorShare > 0) {
|
||||
try {
|
||||
const granted = (await this.credits.grant({
|
||||
userId: deck.ownerUserId,
|
||||
amount: authorShare,
|
||||
reason: 'cards.author-payout',
|
||||
referenceId: purchase.id,
|
||||
description: `Cards-Verkauf: ${deck.title}`,
|
||||
})) as { transactionId?: string };
|
||||
|
||||
[payoutRow] = await this.db
|
||||
.insert(authorPayouts)
|
||||
.values({
|
||||
authorUserId: deck.ownerUserId,
|
||||
sourcePurchaseId: purchase.id,
|
||||
creditsGranted: authorShare,
|
||||
creditsGrantId: granted?.transactionId ?? null,
|
||||
})
|
||||
.returning();
|
||||
} catch (grantErr) {
|
||||
console.warn('[purchases] author grant failed — will retry via reconciler', grantErr);
|
||||
}
|
||||
}
|
||||
|
||||
if (this.notify) {
|
||||
void this.notify.send({
|
||||
channel: 'email',
|
||||
userId: deck.ownerUserId,
|
||||
subject: `Verkauf: „${deck.title}"`,
|
||||
body: `Ein neuer Käufer hat dein Deck „${deck.title}" gekauft. Du hast ${authorShare} Credits gutgeschrieben bekommen.`,
|
||||
data: {
|
||||
type: 'cards.deck.purchased',
|
||||
deckSlug: deck.slug,
|
||||
purchaseId: purchase.id,
|
||||
authorShare,
|
||||
},
|
||||
externalId: `cards.deck.purchased.${purchase.id}`,
|
||||
});
|
||||
}
|
||||
|
||||
return { purchase, payout: payoutRow, alreadyOwned: false };
|
||||
}
|
||||
|
||||
async hasPurchased(buyerUserId: string, deckId: string): Promise<boolean> {
|
||||
const row = await this.db.query.deckPurchases.findFirst({
|
||||
where: and(eq(deckPurchases.buyerUserId, buyerUserId), eq(deckPurchases.deckId, deckId)),
|
||||
});
|
||||
return !!row && !row.refundedAt;
|
||||
}
|
||||
|
||||
async listForBuyer(buyerUserId: string) {
|
||||
const rows = await this.db
|
||||
.select({
|
||||
id: deckPurchases.id,
|
||||
deckId: deckPurchases.deckId,
|
||||
deckSlug: publicDecks.slug,
|
||||
deckTitle: publicDecks.title,
|
||||
priceCredits: deckPurchases.priceCredits,
|
||||
purchasedAt: deckPurchases.purchasedAt,
|
||||
refundedAt: deckPurchases.refundedAt,
|
||||
versionId: deckPurchases.versionId,
|
||||
versionSemver: publicDeckVersions.semver,
|
||||
})
|
||||
.from(deckPurchases)
|
||||
.innerJoin(publicDecks, eq(deckPurchases.deckId, publicDecks.id))
|
||||
.innerJoin(publicDeckVersions, eq(deckPurchases.versionId, publicDeckVersions.id))
|
||||
.where(eq(deckPurchases.buyerUserId, buyerUserId))
|
||||
.orderBy(desc(deckPurchases.purchasedAt));
|
||||
return rows;
|
||||
}
|
||||
|
||||
async listPayoutsForAuthor(authorUserId: string) {
|
||||
const rows = await this.db
|
||||
.select({
|
||||
id: authorPayouts.id,
|
||||
purchaseId: authorPayouts.sourcePurchaseId,
|
||||
creditsGranted: authorPayouts.creditsGranted,
|
||||
grantedAt: authorPayouts.grantedAt,
|
||||
deckSlug: publicDecks.slug,
|
||||
deckTitle: publicDecks.title,
|
||||
priceCredits: deckPurchases.priceCredits,
|
||||
})
|
||||
.from(authorPayouts)
|
||||
.innerJoin(deckPurchases, eq(authorPayouts.sourcePurchaseId, deckPurchases.id))
|
||||
.innerJoin(publicDecks, eq(deckPurchases.deckId, publicDecks.id))
|
||||
.where(eq(authorPayouts.authorUserId, authorUserId))
|
||||
.orderBy(desc(authorPayouts.grantedAt));
|
||||
return rows;
|
||||
}
|
||||
}
|
||||
266
docs/marketplace/archive/code/services/subscriptions.ts
Normal file
266
docs/marketplace/archive/code/services/subscriptions.ts
Normal file
|
|
@ -0,0 +1,266 @@
|
|||
/**
|
||||
* Subscriptions + version reads for Phase δ.
|
||||
*
|
||||
* `subscribe` records the user's intent and stamps the version they
|
||||
* pulled at — so the client can compute a per-card diff against
|
||||
* whatever the deck's `latest_version_id` is now. We don't push the
|
||||
* cards back: that's the client's job (it owns the local Dexie).
|
||||
*
|
||||
* `versionWithCards` returns a version's cards in stable `ord` order
|
||||
* so the client can replay them deterministically into its own DB.
|
||||
*
|
||||
* `diffSince` computes the smart-merge payload server-side: based on
|
||||
* per-card `content_hash`, classify each card in the latest version
|
||||
* as `unchanged | changed | added`, and list the hashes the latest
|
||||
* version no longer has (`removed`). Saves the client from holding
|
||||
* both versions at once.
|
||||
*/
|
||||
|
||||
import { and, asc, eq } from 'drizzle-orm';
|
||||
import type { Database } from '../db/connection';
|
||||
import {
|
||||
deckPurchases,
|
||||
deckSubscriptions,
|
||||
publicDeckCards,
|
||||
publicDeckVersions,
|
||||
publicDecks,
|
||||
} from '../db/schema';
|
||||
import { ConflictError, ForbiddenError, NotFoundError } from '../lib/errors';
|
||||
|
||||
export interface VersionPayload {
|
||||
id: string;
|
||||
semver: string;
|
||||
contentHash: string;
|
||||
publishedAt: Date;
|
||||
changelog: string | null;
|
||||
cards: VersionCardPayload[];
|
||||
}
|
||||
|
||||
export interface VersionCardPayload {
|
||||
contentHash: string;
|
||||
type: string;
|
||||
fields: Record<string, string>;
|
||||
ord: number;
|
||||
}
|
||||
|
||||
export interface DiffPayload {
|
||||
from: string;
|
||||
to: string;
|
||||
added: VersionCardPayload[];
|
||||
changed: { previous: { contentHash: string }; next: VersionCardPayload }[];
|
||||
unchanged: { contentHash: string; ord: number }[];
|
||||
removed: { contentHash: string }[];
|
||||
}
|
||||
|
||||
export class SubscriptionService {
|
||||
constructor(private readonly db: Database) {}
|
||||
|
||||
async subscribe(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');
|
||||
if (deck.isTakedown) throw new ForbiddenError('Deck under takedown');
|
||||
if (!deck.latestVersionId) throw new ConflictError('Deck has no published version yet');
|
||||
// Paid decks need a non-refunded purchase before the user can
|
||||
// subscribe (= pull the cards). The author themselves can
|
||||
// always subscribe to their own paid deck for testing.
|
||||
if (deck.priceCredits > 0 && deck.ownerUserId !== userId) {
|
||||
const purchase = await this.db.query.deckPurchases.findFirst({
|
||||
where: and(eq(deckPurchases.buyerUserId, userId), eq(deckPurchases.deckId, deck.id)),
|
||||
});
|
||||
if (!purchase || purchase.refundedAt) {
|
||||
throw new ForbiddenError('Paid deck — purchase required before subscribing');
|
||||
}
|
||||
}
|
||||
|
||||
await this.db
|
||||
.insert(deckSubscriptions)
|
||||
.values({
|
||||
userId,
|
||||
deckId: deck.id,
|
||||
currentVersionId: deck.latestVersionId,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: [deckSubscriptions.userId, deckSubscriptions.deckId],
|
||||
set: { currentVersionId: deck.latestVersionId },
|
||||
});
|
||||
|
||||
return { deckSlug, latestVersionId: deck.latestVersionId };
|
||||
}
|
||||
|
||||
async unsubscribe(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(deckSubscriptions)
|
||||
.where(and(eq(deckSubscriptions.userId, userId), eq(deckSubscriptions.deckId, deck.id)));
|
||||
}
|
||||
|
||||
async listForUser(userId: string) {
|
||||
const rows = await this.db
|
||||
.select({
|
||||
deckSlug: publicDecks.slug,
|
||||
deckTitle: publicDecks.title,
|
||||
deckDescription: publicDecks.description,
|
||||
deckLatestVersionId: publicDecks.latestVersionId,
|
||||
subscribedAt: deckSubscriptions.subscribedAt,
|
||||
notifyUpdates: deckSubscriptions.notifyUpdates,
|
||||
currentVersionId: deckSubscriptions.currentVersionId,
|
||||
})
|
||||
.from(deckSubscriptions)
|
||||
.innerJoin(publicDecks, eq(publicDecks.id, deckSubscriptions.deckId))
|
||||
.where(eq(deckSubscriptions.userId, userId))
|
||||
.orderBy(deckSubscriptions.subscribedAt);
|
||||
|
||||
return rows.map((r) => ({
|
||||
deckSlug: r.deckSlug,
|
||||
deckTitle: r.deckTitle,
|
||||
deckDescription: r.deckDescription,
|
||||
subscribedAt: r.subscribedAt,
|
||||
notifyUpdates: r.notifyUpdates,
|
||||
currentVersionId: r.currentVersionId,
|
||||
latestVersionId: r.deckLatestVersionId,
|
||||
updateAvailable:
|
||||
r.deckLatestVersionId !== null && r.currentVersionId !== r.deckLatestVersionId,
|
||||
}));
|
||||
}
|
||||
|
||||
async versionWithCards(deckSlug: string, semver: string): Promise<VersionPayload> {
|
||||
const deck = await this.db.query.publicDecks.findFirst({
|
||||
where: eq(publicDecks.slug, deckSlug),
|
||||
});
|
||||
if (!deck) throw new NotFoundError('Deck not found');
|
||||
const version = await this.db.query.publicDeckVersions.findFirst({
|
||||
where: and(eq(publicDeckVersions.deckId, deck.id), eq(publicDeckVersions.semver, semver)),
|
||||
});
|
||||
if (!version) throw new NotFoundError(`Version ${semver} not found`);
|
||||
|
||||
const cards = await this.db
|
||||
.select()
|
||||
.from(publicDeckCards)
|
||||
.where(eq(publicDeckCards.versionId, version.id))
|
||||
.orderBy(asc(publicDeckCards.ord));
|
||||
|
||||
return {
|
||||
id: version.id,
|
||||
semver: version.semver,
|
||||
contentHash: version.contentHash,
|
||||
publishedAt: version.publishedAt,
|
||||
changelog: version.changelog,
|
||||
cards: cards.map((c) => ({
|
||||
contentHash: c.contentHash,
|
||||
type: c.type,
|
||||
fields: c.fields as Record<string, string>,
|
||||
ord: c.ord,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/** Smart-merge diff: tell the client what changed since `fromSemver`. */
|
||||
async diffSince(deckSlug: string, fromSemver: string): Promise<DiffPayload> {
|
||||
const deck = await this.db.query.publicDecks.findFirst({
|
||||
where: eq(publicDecks.slug, deckSlug),
|
||||
});
|
||||
if (!deck) throw new NotFoundError('Deck not found');
|
||||
if (!deck.latestVersionId) throw new NotFoundError('Deck has no published version');
|
||||
|
||||
const latestVersion = await this.db.query.publicDeckVersions.findFirst({
|
||||
where: eq(publicDeckVersions.id, deck.latestVersionId),
|
||||
});
|
||||
if (!latestVersion) throw new NotFoundError('Latest version row missing');
|
||||
|
||||
const fromVersion = await this.db.query.publicDeckVersions.findFirst({
|
||||
where: and(eq(publicDeckVersions.deckId, deck.id), eq(publicDeckVersions.semver, fromSemver)),
|
||||
});
|
||||
if (!fromVersion) throw new NotFoundError(`Version ${fromSemver} not found`);
|
||||
|
||||
// Empty diff if already on latest.
|
||||
if (fromVersion.id === latestVersion.id) {
|
||||
return {
|
||||
from: fromSemver,
|
||||
to: latestVersion.semver,
|
||||
added: [],
|
||||
changed: [],
|
||||
unchanged: [],
|
||||
removed: [],
|
||||
};
|
||||
}
|
||||
|
||||
const [fromCards, toCards] = await Promise.all([
|
||||
this.db
|
||||
.select({ contentHash: publicDeckCards.contentHash, ord: publicDeckCards.ord })
|
||||
.from(publicDeckCards)
|
||||
.where(eq(publicDeckCards.versionId, fromVersion.id)),
|
||||
this.db
|
||||
.select()
|
||||
.from(publicDeckCards)
|
||||
.where(eq(publicDeckCards.versionId, latestVersion.id))
|
||||
.orderBy(asc(publicDeckCards.ord)),
|
||||
]);
|
||||
|
||||
const fromHashes = new Set(fromCards.map((c) => c.contentHash));
|
||||
const toHashes = new Set(toCards.map((c) => c.contentHash));
|
||||
|
||||
// Cards that are still here verbatim.
|
||||
const unchanged: { contentHash: string; ord: number }[] = [];
|
||||
// Brand-new cards (hash not in `from`).
|
||||
const added: VersionCardPayload[] = [];
|
||||
// Cards in `from` that vanished completely.
|
||||
const removed: { contentHash: string }[] = fromCards
|
||||
.filter((c) => !toHashes.has(c.contentHash))
|
||||
.map((c) => ({ contentHash: c.contentHash }));
|
||||
|
||||
// `changed` is hard to detect without a stable card-id across
|
||||
// versions. We approximate by treating ord-position pairs that
|
||||
// neither match nor are in the unchanged set: an "added" at the
|
||||
// same ord as a "removed" → changed. Phase ε's pull-request
|
||||
// pipeline gives us a real card-lineage; until then this
|
||||
// heuristic is good enough.
|
||||
const changed: { previous: { contentHash: string }; next: VersionCardPayload }[] = [];
|
||||
const removedByOrd = new Map<number, string>();
|
||||
for (const c of fromCards) {
|
||||
if (!toHashes.has(c.contentHash)) removedByOrd.set(c.ord, c.contentHash);
|
||||
}
|
||||
|
||||
for (const c of toCards) {
|
||||
if (fromHashes.has(c.contentHash)) {
|
||||
unchanged.push({ contentHash: c.contentHash, ord: c.ord });
|
||||
} else if (removedByOrd.has(c.ord)) {
|
||||
const prevHash = removedByOrd.get(c.ord)!;
|
||||
removedByOrd.delete(c.ord);
|
||||
changed.push({
|
||||
previous: { contentHash: prevHash },
|
||||
next: {
|
||||
contentHash: c.contentHash,
|
||||
type: c.type,
|
||||
fields: c.fields as Record<string, string>,
|
||||
ord: c.ord,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
added.push({
|
||||
contentHash: c.contentHash,
|
||||
type: c.type,
|
||||
fields: c.fields as Record<string, string>,
|
||||
ord: c.ord,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Anything left in removedByOrd is a real removal (not paired up
|
||||
// with a `changed`).
|
||||
const trueRemoved = removed.filter((r) => [...removedByOrd.values()].includes(r.contentHash));
|
||||
|
||||
return {
|
||||
from: fromSemver,
|
||||
to: latestVersion.semver,
|
||||
added,
|
||||
changed,
|
||||
unchanged,
|
||||
removed: trueRemoved,
|
||||
};
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue