From 7dbbf635230a03b8610da5f99197a2231fa27d8a Mon Sep 17 00:00:00 2001 From: Till JS Date: Sat, 9 May 2026 15:13:58 +0200 Subject: [PATCH] =?UTF-8?q?Phase=2012=20R2:=20Marketplace-Backend=20=CE=B1?= =?UTF-8?q?=20+=20=CE=B2=20=E2=80=94=20Authors=20+=20Deck-Init=20+=20Publi?= =?UTF-8?q?sh?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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) --- STATUS.md | 2 +- apps/api/src/db/schema/index.ts | 38 ++ apps/api/src/index.ts | 7 + apps/api/src/lib/marketplace/ai-moderation.ts | 42 ++ apps/api/src/lib/marketplace/slug.ts | 64 +++ apps/api/src/lib/marketplace/version-hash.ts | 38 ++ .../middleware/marketplace/optional-auth.ts | 64 +++ apps/api/src/routes/marketplace/authors.ts | 148 +++++++ apps/api/src/routes/marketplace/decks.ts | 380 ++++++++++++++++++ apps/api/tests/marketplace-slug.test.ts | 54 +++ .../tests/marketplace-version-hash.test.ts | 49 +++ docs/marketplace/archive/code/config.ts | 72 ++++ docs/marketplace/archive/code/index.ts | 140 +++++++ .../archive/code/lib/ai-moderation.ts | 132 ++++++ docs/marketplace/archive/code/lib/credits.ts | 80 ++++ docs/marketplace/archive/code/lib/errors.ts | 63 +++ docs/marketplace/archive/code/lib/hash.ts | 44 ++ docs/marketplace/archive/code/lib/notify.ts | 51 +++ docs/marketplace/archive/code/lib/slug.ts | 58 +++ .../archive/code/middleware/jwt-auth.ts | 56 +++ .../archive/code/middleware/optional-auth.ts | 51 +++ .../archive/code/middleware/service-auth.ts | 18 + .../archive/code/routes/authors.ts | 45 +++ docs/marketplace/archive/code/routes/decks.ts | 87 ++++ .../archive/code/routes/discussions.ts | 52 +++ .../archive/code/routes/engagement.ts | 39 ++ .../archive/code/routes/explore.ts | 40 ++ .../archive/code/routes/moderation.ts | 96 +++++ .../archive/code/routes/pull-requests.ts | 99 +++++ .../archive/code/routes/purchases.ts | 33 ++ .../archive/code/routes/subscriptions.ts | 56 +++ .../archive/code/services/authors.ts | 104 +++++ .../archive/code/services/decks.ts | 223 ++++++++++ .../archive/code/services/discussions.ts | 109 +++++ .../archive/code/services/engagement.ts | 79 ++++ .../archive/code/services/explore.ts | 195 +++++++++ .../archive/code/services/moderation.ts | 280 +++++++++++++ .../archive/code/services/pull-requests.ts | 318 +++++++++++++++ .../archive/code/services/purchases.ts | 233 +++++++++++ .../archive/code/services/subscriptions.ts | 266 ++++++++++++ 40 files changed, 4004 insertions(+), 1 deletion(-) create mode 100644 apps/api/src/lib/marketplace/ai-moderation.ts create mode 100644 apps/api/src/lib/marketplace/slug.ts create mode 100644 apps/api/src/lib/marketplace/version-hash.ts create mode 100644 apps/api/src/middleware/marketplace/optional-auth.ts create mode 100644 apps/api/src/routes/marketplace/authors.ts create mode 100644 apps/api/src/routes/marketplace/decks.ts create mode 100644 apps/api/tests/marketplace-slug.test.ts create mode 100644 apps/api/tests/marketplace-version-hash.test.ts create mode 100644 docs/marketplace/archive/code/config.ts create mode 100644 docs/marketplace/archive/code/index.ts create mode 100644 docs/marketplace/archive/code/lib/ai-moderation.ts create mode 100644 docs/marketplace/archive/code/lib/credits.ts create mode 100644 docs/marketplace/archive/code/lib/errors.ts create mode 100644 docs/marketplace/archive/code/lib/hash.ts create mode 100644 docs/marketplace/archive/code/lib/notify.ts create mode 100644 docs/marketplace/archive/code/lib/slug.ts create mode 100644 docs/marketplace/archive/code/middleware/jwt-auth.ts create mode 100644 docs/marketplace/archive/code/middleware/optional-auth.ts create mode 100644 docs/marketplace/archive/code/middleware/service-auth.ts create mode 100644 docs/marketplace/archive/code/routes/authors.ts create mode 100644 docs/marketplace/archive/code/routes/decks.ts create mode 100644 docs/marketplace/archive/code/routes/discussions.ts create mode 100644 docs/marketplace/archive/code/routes/engagement.ts create mode 100644 docs/marketplace/archive/code/routes/explore.ts create mode 100644 docs/marketplace/archive/code/routes/moderation.ts create mode 100644 docs/marketplace/archive/code/routes/pull-requests.ts create mode 100644 docs/marketplace/archive/code/routes/purchases.ts create mode 100644 docs/marketplace/archive/code/routes/subscriptions.ts create mode 100644 docs/marketplace/archive/code/services/authors.ts create mode 100644 docs/marketplace/archive/code/services/decks.ts create mode 100644 docs/marketplace/archive/code/services/discussions.ts create mode 100644 docs/marketplace/archive/code/services/engagement.ts create mode 100644 docs/marketplace/archive/code/services/explore.ts create mode 100644 docs/marketplace/archive/code/services/moderation.ts create mode 100644 docs/marketplace/archive/code/services/pull-requests.ts create mode 100644 docs/marketplace/archive/code/services/purchases.ts create mode 100644 docs/marketplace/archive/code/services/subscriptions.ts diff --git a/STATUS.md b/STATUS.md index 220b968..6d38d0e 100644 --- a/STATUS.md +++ b/STATUS.md @@ -98,7 +98,7 @@ Vollständiger Plan: [`mana/docs/playbooks/CARDS_GREENFIELD.md`](../mana/docs/pl | 9 | Polish (DSGVO-UI, Settings, Account, Statistik, i18n, A11y, Media, Image-Occlusion) | 🟡 weit | Card-Edit + Cloze-Editor + Inbox-Banner + Account/DSGVO + Statistik + Pre-Flight-Swap + i18n DE/EN + A11y-Pass + Cloze-Hint-Anzeige + Anki-Re-Import-Dedupe + MinIO-Media-Upload + Image-Occlusion durch (9a–9l). Verbleibend: type-in, audio, multiple-choice (Schema vorbereitet) | | 10 | Production-Deploy (Mac Mini, Cloudflare-Tunnel) | ✅ live 2026-05-08 | cardecky.mana.how + cardecky-api.mana.how, alte cards.* via nginx-301-Redirect | | 11 | Decommission Cards-Modul aus mana-monorepo | ✅ 2026-05-08 | apps/cards, services/cards-server, packages/cards-core, mana-app cards-Modul + cross-refs entfernt (4 Commits, type-check 0 errors) | -| 12 | Marketplace-Restore (R0–R6) | 🟡 R0+R1 durch | Plan: [`docs/playbooks/MARKETPLACE_RESTORE.md`](docs/playbooks/MARKETPLACE_RESTORE.md). R0 (Doku-Archiv aus `cards-decommission-base` + Restore-Plan + Strategie-B-Klarstellung in CLAUDE.md): ✅. R1 (16 Tabellen + 5 Enums in `marketplace`-pgSchema, drizzle-kit push grün, type-check + 56 Tests grün, CHECK-Constraint `decks_price_requires_license` verifiziert): ✅. Verbleibend: R2 Backend α/β (Author-Profile + Publish + AI-Mod), R3 γ/δ (Discovery + Subscribe + Smart-Merge), R4 ε (PRs + Discussions), R5 Frontend-Routes, R6 E2E-Smoke. | +| 12 | Marketplace-Restore (R0–R6) | 🟡 R0+R1+R2 durch | Plan: [`docs/playbooks/MARKETPLACE_RESTORE.md`](docs/playbooks/MARKETPLACE_RESTORE.md). R0 (Doku-Archiv + Restore-Plan + Strategie-B-Klarstellung): ✅. R1 (16 Tabellen + 5 Enums in `marketplace`-pgSchema, CHECK-Constraint verifiziert): ✅. R2 (Backend α + β): ✅ — Author-Routen (`POST/GET /authors/me`, `GET /authors/:slug`), Deck-Init (`POST /decks`), Publish-Flow (`POST /decks/:slug/publish` mit @cards/domain-Hash + per-Version-Hash + AI-Mod-Stub-Log + atomarem latest_version_id-Bump), PATCH-Metadaten, Public-Detail mit optional-auth. 16 neue Tests (72 gesamt) grün, E2E-Smoke gegen lokale cards-api durch (Cardecky-Author + Deck `r2-stoische-ethik` mit 3 Karten v1.0.0, semver-409 + paid-422-Errors verifiziert). Code-Referenz aus altem cards-server unter `docs/marketplace/archive/code/` (3331 LOC). Verbleibend: R3 γ/δ (Discovery + Subscribe + Smart-Merge), R4 ε (PRs + Discussions), R5 Frontend-Routes, R6 voller E2E-Smoke. | Legende: ✅ erledigt + verifiziert · 🚧 blockiert · ⏸ noch nicht begonnen diff --git a/apps/api/src/db/schema/index.ts b/apps/api/src/db/schema/index.ts index 365dd64..e4e8b4c 100644 --- a/apps/api/src/db/schema/index.ts +++ b/apps/api/src/db/schema/index.ts @@ -32,3 +32,41 @@ export type { export { importJobs } from './imports.ts'; export type { ImportJobRow, ImportJobInsert } from './imports.ts'; + +// Marketplace-Schema (Phase 12 R1, eigenes pgSchema('marketplace')). +// Re-Exports tragen den `public`-Prefix aus der Original-Implementation +// (`publicDecks`/`publicDeckVersions`/`publicDeckCards`), damit sie +// nicht mit den oben gelisteten privaten Lern-Tabellen kollidieren. +export { + marketplaceSchema, + authors, + authorFollows, + publicDecks, + publicDeckVersions, + publicDeckCards, + cardTypeEnum, + tagDefinitions, + deckTags, + deckStars, + deckSubscriptions, + deckForks, + deckPullRequests, + cardDiscussions, + pullRequestStatusEnum, + deckReports, + aiModerationLog, + reportCategoryEnum, + reportStatusEnum, + aiModerationVerdictEnum, + deckPurchases, + authorPayouts, +} from './marketplace/index.ts'; +export type { + AuthorRow, + AuthorInsert, + PublicDeckRow, + PublicDeckInsert, + PublicDeckVersionRow, + PublicDeckCardRow, + PullRequestDiff, +} from './marketplace/index.ts'; diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 2b9ed34..551fb67 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -13,6 +13,8 @@ import { dsgvoRouter } from './routes/dsgvo.ts'; import { meRouter } from './routes/me.ts'; import { mediaRouter } from './routes/media.ts'; import { decksGenerateRouter } from './routes/decks-generate.ts'; +import { authorsRouter as marketplaceAuthorsRouter } from './routes/marketplace/authors.ts'; +import { marketplaceDecksRouter } from './routes/marketplace/decks.ts'; const app = new Hono(); @@ -46,6 +48,11 @@ app.route('/api/v1/me', meRouter()); app.route('/api/v1/media', mediaRouter()); app.route('/api/v1/decks/generate', decksGenerateRouter()); +// Marketplace (Phase 12). Eigenes pgSchema, additive Routen unter /v1/marketplace/*. +// Plan: docs/playbooks/MARKETPLACE_RESTORE.md. +app.route('/api/v1/marketplace/authors', marketplaceAuthorsRouter()); +app.route('/api/v1/marketplace/decks', marketplaceDecksRouter()); + app.get('/', (c) => c.json({ app: 'cards', diff --git a/apps/api/src/lib/marketplace/ai-moderation.ts b/apps/api/src/lib/marketplace/ai-moderation.ts new file mode 100644 index 0000000..2326173 --- /dev/null +++ b/apps/api/src/lib/marketplace/ai-moderation.ts @@ -0,0 +1,42 @@ +/** + * AI-Moderation-First-Pass für Deck-Publishes. + * + * Verdict: `pass` | `flag` | `block`. Per Mission-Prinzip „AI ist + * Moderator, nicht Gatekeeper" wird `block` nur für unmissverständliche + * Verstöße benutzt (CSAM, Doxxing); alles ambivalente fließt zur + * menschlichen Review als `flag`. + * + * **Aktueller Stand: Stub.** R2 implementiert nur den Pass-Through + * (`verdict='pass'`, `model='stub'`) plus den Audit-Log-Eintrag. + * Echte mana-llm-Integration kommt in einer späteren Welle (siehe + * cards-decommission-base:services/cards-server/src/lib/ai-moderation.ts + * für die alte Voll-Implementation als Lese-Referenz unter + * `docs/marketplace/archive/code/lib/ai-moderation.ts`). + * + * Fail-open im Original: bei mana-llm-Ausfall wurde `flag` gesetzt, + * damit ein menschlicher Reviewer es trotzdem sieht. Solange wir nur + * Cardecky-Decks publishen, ist der Stub `pass` ausreichend — Cardecky + * ist eine kuratierte Identität. + */ + +export interface ModerationVerdict { + verdict: 'pass' | 'flag' | 'block'; + categories: string[]; + rationale: string; + model: string; +} + +export interface ModerationInput { + title: string; + description?: string; + cards: { fields: Record }[]; +} + +export async function moderateDeckContent(_input: ModerationInput): Promise { + return { + verdict: 'pass', + categories: [], + rationale: 'R2-stub: AI-Mod nicht aktiv, alles passt durch.', + model: 'stub', + }; +} diff --git a/apps/api/src/lib/marketplace/slug.ts b/apps/api/src/lib/marketplace/slug.ts new file mode 100644 index 0000000..284f4b0 --- /dev/null +++ b/apps/api/src/lib/marketplace/slug.ts @@ -0,0 +1,64 @@ +/** + * URL-safe Slug-Helpers für Marketplace-Author-Profile + Deck-URLs. + * + * `slugify` ist best-effort — macht „Anna Lang!" zu „anna-lang" — als + * Vorschlag. `validateSlug` ist strikt und wird auf jeden Write + * enforced, damit der URL-Raum vorhersehbar bleibt. + * + * 1:1 ported aus + * `cards-decommission-base:services/cards-server/src/lib/slug.ts`. + */ + +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', + 'marketplace', + '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 type SlugInvalidReason = 'too-short' | 'too-long' | 'invalid-chars' | 'reserved'; + +export interface SlugValidation { + ok: boolean; + reason?: SlugInvalidReason; +} + +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 }; +} diff --git a/apps/api/src/lib/marketplace/version-hash.ts b/apps/api/src/lib/marketplace/version-hash.ts new file mode 100644 index 0000000..49b1a74 --- /dev/null +++ b/apps/api/src/lib/marketplace/version-hash.ts @@ -0,0 +1,38 @@ +/** + * Per-Version-Content-Hash. Pro Karte wird `cardContentHash` aus + * `@cards/domain` benutzt (gemeinsame Source-of-Truth zwischen privatem + * Lern-Stack und Marketplace) — pro Version hashen wir die geordnete + * Liste von Karten-Hashes plus deren Ordinal-Position. + * + * Smart-Merge nutzt das: zwei Versionen mit identischen Karten in + * identischer Reihenfolge bekommen denselben Version-Hash; eine + * Reihenfolgen-Änderung allein ändert den Version-Hash, weil das aus + * Lern-Sicht ein anderer Verlauf ist. + * + * Original-cards-server hatte eine eigene `hashCard`/`hashVersionCards`- + * Implementation; der Greenfield-`cardContentHash` benutzt aber ein + * leicht anderes Canonical-Format (sortiertes Tupel-Array statt + * sortiertes Object). Wir vereinheitlichen auf das Greenfield-Format, + * weil ein Fork eines Marketplace-Decks die Karten-Hashes mit den + * privaten `cards.cards.content_hash` matchen können soll. + */ + +import { cardContentHash } from '@cards/domain'; + +export interface OrderedCardForHash { + type: string; + fields: Record; + ord: number; +} + +export async function hashVersionCards(cards: OrderedCardForHash[]): Promise { + const ordered = [...cards].sort((a, b) => a.ord - b.ord); + const cardHashes = await Promise.all( + ordered.map(async (c) => `${c.ord}:${await cardContentHash({ type: c.type, fields: c.fields })}`) + ); + const data = new TextEncoder().encode(cardHashes.join('|')); + const buf = await crypto.subtle.digest('SHA-256', data); + return Array.from(new Uint8Array(buf)) + .map((b) => b.toString(16).padStart(2, '0')) + .join(''); +} diff --git a/apps/api/src/middleware/marketplace/optional-auth.ts b/apps/api/src/middleware/marketplace/optional-auth.ts new file mode 100644 index 0000000..f1a35da --- /dev/null +++ b/apps/api/src/middleware/marketplace/optional-auth.ts @@ -0,0 +1,64 @@ +import type { MiddlewareHandler } from 'hono'; +import { createRemoteJWKSet, jwtVerify } from 'jose'; + +import type { AuthVars, Tier } from '../auth.ts'; + +/** + * Optional-Auth-Middleware für Marketplace-Public-Endpoints. + * + * Setzt `userId`/`tier`/`authMode` wenn ein gültiger Bearer-Token da + * ist, lehnt aber **nie** ab. Public-Read (Explore, Deck-Detail, Author- + * Profile) funktioniert anonym; signed-in User sehen zusätzlich ihren + * Star/Subscribe/Fork-State. + * + * Schwester-Middleware zu `authMiddleware` (strict, in `../auth.ts`). + * Beide nutzen denselben JWKS-Cache + dasselbe Algo, dieser ist nur + * der Read-Path-Companion. + * + * Geschichte: 1:1 ported from `cards-decommission-base:services/cards-server/src/middleware/optional-auth.ts`, + * mit Anpassung auf den Greenfield-`AuthVars`-Shape (`userId`/`tier` + * statt `user`-Object). + */ + +const MANA_AUTH_URL = process.env.MANA_AUTH_URL ?? 'https://auth.mana.how'; + +let jwksCache: ReturnType | null = null; +function getJwks() { + if (!jwksCache) { + jwksCache = createRemoteJWKSet(new URL('/api/auth/jwks', MANA_AUTH_URL)); + } + return jwksCache; +} + +function tierFromClaim(raw: unknown): Tier { + if (typeof raw !== 'string') return 'public'; + if (raw === 'guest' || raw === 'public' || raw === 'beta' || raw === 'alpha' || raw === 'founder') { + return raw; + } + return 'public'; +} + +export const optionalAuthMiddleware: MiddlewareHandler<{ Variables: Partial }> = async ( + c, + next +) => { + const authHeader = c.req.header('Authorization'); + if (!authHeader?.startsWith('Bearer ')) { + await next(); + return; + } + const token = authHeader.slice(7).trim(); + try { + const { payload } = await jwtVerify(token, getJwks(), {}); + const sub = typeof payload.sub === 'string' ? payload.sub : null; + if (sub) { + c.set('userId', sub); + c.set('tier', tierFromClaim(payload.tier)); + c.set('authMode', 'jwt'); + } + } catch { + // Bad Token = anonymous fortfahren. Strict-Middleware ist für + // Mutation-Endpoints zuständig. + } + await next(); +}; diff --git a/apps/api/src/routes/marketplace/authors.ts b/apps/api/src/routes/marketplace/authors.ts new file mode 100644 index 0000000..ee33e2b --- /dev/null +++ b/apps/api/src/routes/marketplace/authors.ts @@ -0,0 +1,148 @@ +import { eq } from 'drizzle-orm'; +import { Hono } from 'hono'; +import { z } from 'zod'; + +import { getDb, type CardsDb } from '../../db/connection.ts'; +import { authors } from '../../db/schema/index.ts'; +import { authMiddleware, type AuthVars } from '../../middleware/auth.ts'; +import { validateSlug } from '../../lib/marketplace/slug.ts'; + +/** + * Author-Routen für den Marketplace. + * + * - `POST /me` — eigenes Author-Profil anlegen oder updaten (Slug, + * Display-Name, Bio, Avatar, Pseudonym-Modus). Idempotent: existiert + * bereits ein Profil zur User-ID, wird es ge-upsertet. + * - `GET /me` — eigenes Profil lesen, `null` wenn nicht angelegt. + * - `GET /:slug` — public Profile-Lookup. Banned-Reason wird gestrippt. + * + * Geschichte: ported aus + * `cards-decommission-base:services/cards-server/src/routes/authors.ts` + * + `services/authors.ts`. Auth-Shape an Greenfield angepasst (`userId` + * aus `c.get('userId')`, nicht `c.get('user').userId`). + */ + +export type AuthorsDeps = { db?: CardsDb }; + +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 toPublicProfile(row: { + slug: string; + displayName: string; + bio: string | null; + avatarUrl: string | null; + joinedAt: Date; + pseudonym: boolean; + verifiedMana: boolean; + verifiedCommunity: boolean; + bannedAt: Date | null; +}) { + return { + slug: row.slug, + display_name: row.displayName, + bio: row.bio, + avatar_url: row.avatarUrl, + joined_at: row.joinedAt.toISOString(), + pseudonym: row.pseudonym, + verified_mana: row.verifiedMana, + verified_community: row.verifiedCommunity, + banned: row.bannedAt !== null, + }; +} + +export function authorsRouter(deps: AuthorsDeps = {}): Hono<{ Variables: AuthVars }> { + const r = new Hono<{ Variables: AuthVars }>(); + const dbOf = () => deps.db ?? getDb(); + + // /me/* benötigt Auth. /:slug ist public — wir splitten am Router. + const meRouter = new Hono<{ Variables: AuthVars }>(); + meRouter.use('*', authMiddleware); + + meRouter.post('/', async (c) => { + const userId = c.get('userId'); + const body = await c.req.json().catch(() => null); + const parsed = UpsertSchema.safeParse(body); + if (!parsed.success) { + return c.json( + { error: 'invalid_input', issues: parsed.error.issues.map((i) => i.message) }, + 422 + ); + } + + const validation = validateSlug(parsed.data.slug); + if (!validation.ok) { + return c.json({ error: 'invalid_slug', reason: validation.reason }, 422); + } + + const db = dbOf(); + + // Slug muss frei sein oder uns gehören. + const [bySlug] = await db + .select() + .from(authors) + .where(eq(authors.slug, parsed.data.slug)) + .limit(1); + if (bySlug && bySlug.userId !== userId) { + return c.json({ error: 'slug_taken' }, 409); + } + + const [existing] = await db + .select() + .from(authors) + .where(eq(authors.userId, userId)) + .limit(1); + + if (existing) { + const [updated] = await db + .update(authors) + .set({ + slug: parsed.data.slug, + displayName: parsed.data.displayName, + bio: parsed.data.bio, + avatarUrl: parsed.data.avatarUrl, + pseudonym: parsed.data.pseudonym ?? existing.pseudonym, + }) + .where(eq(authors.userId, userId)) + .returning(); + return c.json(toPublicProfile(updated)); + } + + const [created] = await db + .insert(authors) + .values({ + userId, + slug: parsed.data.slug, + displayName: parsed.data.displayName, + bio: parsed.data.bio, + avatarUrl: parsed.data.avatarUrl, + pseudonym: parsed.data.pseudonym ?? false, + }) + .returning(); + return c.json(toPublicProfile(created), 201); + }); + + meRouter.get('/', async (c) => { + const userId = c.get('userId'); + const [row] = await dbOf().select().from(authors).where(eq(authors.userId, userId)).limit(1); + if (!row) return c.json(null); + return c.json(toPublicProfile(row)); + }); + + r.route('/me', meRouter); + + // Public Profile-Lookup. + r.get('/:slug', async (c) => { + const slug = c.req.param('slug'); + const [row] = await dbOf().select().from(authors).where(eq(authors.slug, slug)).limit(1); + if (!row) return c.json({ error: 'not_found' }, 404); + return c.json(toPublicProfile(row)); + }); + + return r; +} diff --git a/apps/api/src/routes/marketplace/decks.ts b/apps/api/src/routes/marketplace/decks.ts new file mode 100644 index 0000000..9e90d4e --- /dev/null +++ b/apps/api/src/routes/marketplace/decks.ts @@ -0,0 +1,380 @@ +import { and, eq } from 'drizzle-orm'; +import { Hono } from 'hono'; +import { z } from 'zod'; + +import { cardContentHash } from '@cards/domain'; + +import { getDb, type CardsDb } from '../../db/connection.ts'; +import { + aiModerationLog, + authors, + publicDeckCards, + publicDeckVersions, + publicDecks, +} from '../../db/schema/index.ts'; +import { authMiddleware, type AuthVars } from '../../middleware/auth.ts'; +import { optionalAuthMiddleware } from '../../middleware/marketplace/optional-auth.ts'; +import { moderateDeckContent } from '../../lib/marketplace/ai-moderation.ts'; +import { validateSlug } from '../../lib/marketplace/slug.ts'; +import { hashVersionCards } from '../../lib/marketplace/version-hash.ts'; + +/** + * Deck-Routen für den Marketplace. + * + * - `POST /` — Deck-Init (Slug, Titel, optionale Lizenz/Preis). + * - `GET /:slug` — Public-Deck-Detail mit `latest_version`. + * Optional-Auth: signed-in User sieht später + * zusätzlich Subscribe/Star-State (R3). + * - `POST /:slug/publish` — Neue Version publishen. Nur Owner. + * Karten + Hashes + AI-Mod-Log + atomarer + * Bump des `latest_version_id`. + * - `PATCH /:slug` — Metadaten-Update. Nur Owner. + * + * Geschichte: ported aus + * `cards-decommission-base:services/cards-server/src/{routes,services}/decks.ts`, + * mit Greenfield-Anpassungen (Hashing über `@cards/domain`, + * AI-Mod-Stub statt mana-llm-Call, Auth-Vars-Shape). + */ + +export type MarketplaceDecksDeps = { db?: CardsDb }; + +const SEMVER_RE = /^(\d+)\.(\d+)\.(\d+)$/; + +const InitSchema = z.object({ + slug: z.string(), + title: z.string().min(1).max(120), + description: z.string().max(2000).optional(), + language: z + .string() + .regex(/^[a-z]{2}$/, 'language must be ISO-639-1 (e.g. de, en)') + .optional(), + license: z.string().max(60).optional(), + priceCredits: z.number().int().min(0).max(100_000).optional(), +}); + +const PatchSchema = z.object({ + title: z.string().min(1).max(120).optional(), + description: z.string().max(2000).optional(), + language: z.string().regex(/^[a-z]{2}$/).optional(), + license: z.string().max(60).optional(), + priceCredits: z.number().int().min(0).max(100_000).optional(), +}); + +const CardTypeSchema = z.enum([ + 'basic', + 'basic-reverse', + 'cloze', + 'type-in', + 'image-occlusion', + 'audio', + 'multiple-choice', +]); + +const PublishSchema = z.object({ + semver: z.string().regex(SEMVER_RE, 'semver must look like 1.0.0'), + changelog: z.string().max(2000).optional(), + cards: z + .array( + z.object({ + type: CardTypeSchema, + fields: z.record(z.string()), + }) + ) + .min(1, 'A version needs at least one card'), +}); + +function semverGreater(a: string, b: string): boolean { + const ma = a.match(SEMVER_RE); + const mb = b.match(SEMVER_RE); + if (!ma || !mb) return false; + for (let i = 1; i <= 3; i++) { + const da = Number.parseInt(ma[i], 10); + const db = Number.parseInt(mb[i], 10); + if (da > db) return true; + if (da < db) return false; + } + return false; +} + +function toDeckDto(row: typeof publicDecks.$inferSelect) { + return { + id: row.id, + slug: row.slug, + title: row.title, + description: row.description, + language: row.language, + license: row.license, + price_credits: row.priceCredits, + owner_user_id: row.ownerUserId, + latest_version_id: row.latestVersionId, + is_featured: row.isFeatured, + is_takedown: row.isTakedown, + created_at: row.createdAt.toISOString(), + }; +} + +function toVersionDto(row: typeof publicDeckVersions.$inferSelect) { + return { + id: row.id, + deck_id: row.deckId, + semver: row.semver, + changelog: row.changelog, + content_hash: row.contentHash, + card_count: row.cardCount, + published_at: row.publishedAt.toISOString(), + deprecated_at: row.deprecatedAt?.toISOString() ?? null, + }; +} + +export function marketplaceDecksRouter( + deps: MarketplaceDecksDeps = {} +): Hono<{ Variables: Partial }> { + const r = new Hono<{ Variables: Partial }>(); + const dbOf = () => deps.db ?? getDb(); + + // Path-scoped Middleware: GET /:slug ist optional-auth (anonymer + // Read erlaubt), alles andere strict-auth. + r.get('/:slug', optionalAuthMiddleware, async (c) => { + const slug = c.req.param('slug'); + const db = dbOf(); + const [deck] = await db.select().from(publicDecks).where(eq(publicDecks.slug, slug)).limit(1); + if (!deck) return c.json({ error: 'not_found' }, 404); + + let version: typeof publicDeckVersions.$inferSelect | null = null; + if (deck.latestVersionId) { + const [v] = await db + .select() + .from(publicDeckVersions) + .where(eq(publicDeckVersions.id, deck.latestVersionId)) + .limit(1); + version = v ?? null; + } + + return c.json({ + deck: toDeckDto(deck), + latest_version: version ? toVersionDto(version) : null, + }); + }); + + // Authenticated endpoints folgen. + const auth = new Hono<{ Variables: AuthVars }>(); + auth.use('*', authMiddleware); + + // POST / — Deck-Init. + auth.post('/', async (c) => { + const userId = c.get('userId'); + const body = await c.req.json().catch(() => null); + const parsed = InitSchema.safeParse(body); + if (!parsed.success) { + return c.json( + { error: 'invalid_input', issues: parsed.error.issues.map((i) => i.message) }, + 422 + ); + } + + const validation = validateSlug(parsed.data.slug); + if (!validation.ok) { + return c.json({ error: 'invalid_slug', reason: validation.reason }, 422); + } + + const license = parsed.data.license ?? 'Cardecky-Personal-Use-1.0'; + const priceCredits = parsed.data.priceCredits ?? 0; + if (priceCredits > 0 && license !== 'Cardecky-Pro-Only-1.0') { + return c.json( + { + error: 'paid_decks_require_pro_license', + detail: 'priceCredits > 0 ⇒ license must be Cardecky-Pro-Only-1.0', + }, + 422 + ); + } + + const db = dbOf(); + + // Author-Profil-Pflicht — kein Decksanlegen ohne authors-Row. + const [author] = await db.select().from(authors).where(eq(authors.userId, userId)).limit(1); + if (!author) { + return c.json( + { error: 'no_author_profile', detail: 'POST /api/v1/marketplace/authors/me first' }, + 400 + ); + } + if (author.bannedAt) { + return c.json({ error: 'author_banned', reason: author.bannedReason ?? 'no_reason' }, 403); + } + + const [bySlug] = await db + .select() + .from(publicDecks) + .where(eq(publicDecks.slug, parsed.data.slug)) + .limit(1); + if (bySlug) return c.json({ error: 'slug_taken' }, 409); + + const [created] = await db + .insert(publicDecks) + .values({ + slug: parsed.data.slug, + title: parsed.data.title, + description: parsed.data.description, + language: parsed.data.language, + license, + priceCredits, + ownerUserId: userId, + }) + .returning(); + return c.json(toDeckDto(created), 201); + }); + + // PATCH /:slug — Metadaten. + auth.patch('/:slug', async (c) => { + const userId = c.get('userId'); + const slug = c.req.param('slug'); + const body = await c.req.json().catch(() => null); + const parsed = PatchSchema.safeParse(body); + if (!parsed.success) { + return c.json( + { error: 'invalid_input', issues: parsed.error.issues.map((i) => i.message) }, + 422 + ); + } + + const db = dbOf(); + const [deck] = await db.select().from(publicDecks).where(eq(publicDecks.slug, slug)).limit(1); + if (!deck) return c.json({ error: 'not_found' }, 404); + if (deck.ownerUserId !== userId) return c.json({ error: 'forbidden' }, 403); + if (deck.isTakedown) return c.json({ error: 'takedown_active' }, 403); + + const license = parsed.data.license ?? deck.license; + const priceCredits = parsed.data.priceCredits ?? deck.priceCredits; + if (priceCredits > 0 && license !== 'Cardecky-Pro-Only-1.0') { + return c.json({ error: 'paid_decks_require_pro_license' }, 422); + } + + const [updated] = await db + .update(publicDecks) + .set({ + ...(parsed.data.title !== undefined && { title: parsed.data.title }), + ...(parsed.data.description !== undefined && { description: parsed.data.description }), + ...(parsed.data.language !== undefined && { language: parsed.data.language }), + ...(parsed.data.license !== undefined && { license }), + ...(parsed.data.priceCredits !== undefined && { priceCredits }), + }) + .where(and(eq(publicDecks.id, deck.id))) + .returning(); + return c.json(toDeckDto(updated)); + }); + + // POST /:slug/publish — neue Version. + auth.post('/:slug/publish', async (c) => { + const userId = c.get('userId'); + const slug = c.req.param('slug'); + const body = await c.req.json().catch(() => null); + const parsed = PublishSchema.safeParse(body); + if (!parsed.success) { + return c.json( + { error: 'invalid_input', issues: parsed.error.issues.map((i) => i.message) }, + 422 + ); + } + + const db = dbOf(); + const [deck] = await db.select().from(publicDecks).where(eq(publicDecks.slug, slug)).limit(1); + if (!deck) return c.json({ error: 'not_found' }, 404); + if (deck.ownerUserId !== userId) return c.json({ error: 'forbidden' }, 403); + if (deck.isTakedown) return c.json({ error: 'takedown_active' }, 403); + + // Semver muss strikt größer als latest sein — lineare History. + if (deck.latestVersionId) { + const [latest] = await db + .select() + .from(publicDeckVersions) + .where(eq(publicDeckVersions.id, deck.latestVersionId)) + .limit(1); + if (latest && !semverGreater(parsed.data.semver, latest.semver)) { + return c.json( + { error: 'semver_not_greater', latest: latest.semver, got: parsed.data.semver }, + 409 + ); + } + } + + // 1) AI-Mod (Stub in R2). + const moderation = await moderateDeckContent({ + title: deck.title, + description: deck.description ?? undefined, + cards: parsed.data.cards.map((card) => ({ fields: card.fields })), + }); + if (moderation.verdict === 'block') { + return c.json( + { error: 'moderation_block', categories: moderation.categories, rationale: moderation.rationale }, + 403 + ); + } + + // 2) Hashes berechnen. + const cardsWithOrd = parsed.data.cards.map((card, i) => ({ ...card, ord: i })); + const versionContentHash = await hashVersionCards(cardsWithOrd); + const cardHashes = await Promise.all( + cardsWithOrd.map((card) => cardContentHash({ type: card.type, fields: card.fields })) + ); + + // 3) Insert Version + Cards + AI-Log + flip latest_version_id atomar. + const result = await db.transaction(async (tx) => { + const [version] = await tx + .insert(publicDeckVersions) + .values({ + deckId: deck.id, + semver: parsed.data.semver, + changelog: parsed.data.changelog, + contentHash: versionContentHash, + cardCount: cardsWithOrd.length, + }) + .returning(); + + await tx.insert(publicDeckCards).values( + cardsWithOrd.map((card, i) => ({ + versionId: version.id, + type: card.type, + fields: card.fields, + ord: card.ord, + contentHash: cardHashes[i], + })) + ); + + 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(eq(publicDecks.id, deck.id)) + .returning(); + + return { deck: updatedDeck, version }; + }); + + return c.json( + { + deck: toDeckDto(result.deck), + version: toVersionDto(result.version), + moderation: { + verdict: moderation.verdict, + categories: moderation.categories, + model: moderation.model, + }, + }, + 201 + ); + }); + + // Auth-Sub-Router gemountet auf '/'. Hono routet zuerst exakte + // Pfade auf `r` (GET /:slug), Rest fließt auf den auth-Mount. + r.route('/', auth); + + return r; +} diff --git a/apps/api/tests/marketplace-slug.test.ts b/apps/api/tests/marketplace-slug.test.ts new file mode 100644 index 0000000..6c99fa8 --- /dev/null +++ b/apps/api/tests/marketplace-slug.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest'; + +import { slugify, validateSlug } from '../src/lib/marketplace/slug.ts'; + +describe('slugify', () => { + it('lowercases and dasherizes', () => { + expect(slugify('Anna Lang!')).toBe('anna-lang'); + }); + + it('strips leading/trailing dashes', () => { + expect(slugify(' hello world ')).toBe('hello-world'); + }); + + it('drops diacritics', () => { + expect(slugify('Café Crème')).toBe('cafe-creme'); + }); + + it('caps at 60 chars', () => { + const slug = slugify('a'.repeat(120)); + expect(slug.length).toBeLessThanOrEqual(60); + }); +}); + +describe('validateSlug', () => { + it('accepts simple lowercase slugs', () => { + expect(validateSlug('anna-lang')).toEqual({ ok: true }); + }); + + it('rejects too short', () => { + expect(validateSlug('ab')).toEqual({ ok: false, reason: 'too-short' }); + }); + + it('rejects too long', () => { + expect(validateSlug('a'.repeat(70))).toEqual({ ok: false, reason: 'too-long' }); + }); + + it('rejects uppercase', () => { + expect(validateSlug('Anna-Lang')).toEqual({ ok: false, reason: 'invalid-chars' }); + }); + + it('rejects underscore', () => { + expect(validateSlug('anna_lang')).toEqual({ ok: false, reason: 'invalid-chars' }); + }); + + it('rejects leading dash', () => { + expect(validateSlug('-anna')).toEqual({ ok: false, reason: 'invalid-chars' }); + }); + + it('rejects reserved slugs', () => { + expect(validateSlug('admin')).toEqual({ ok: false, reason: 'reserved' }); + expect(validateSlug('explore')).toEqual({ ok: false, reason: 'reserved' }); + expect(validateSlug('marketplace')).toEqual({ ok: false, reason: 'reserved' }); + }); +}); diff --git a/apps/api/tests/marketplace-version-hash.test.ts b/apps/api/tests/marketplace-version-hash.test.ts new file mode 100644 index 0000000..9209b3b --- /dev/null +++ b/apps/api/tests/marketplace-version-hash.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest'; + +import { cardContentHash } from '@cards/domain'; + +import { hashVersionCards } from '../src/lib/marketplace/version-hash.ts'; + +describe('hashVersionCards', () => { + const card1 = { type: 'basic', fields: { front: 'Q1', back: 'A1' }, ord: 0 }; + const card2 = { type: 'basic', fields: { front: 'Q2', back: 'A2' }, ord: 1 }; + + it('is deterministic', async () => { + const a = await hashVersionCards([card1, card2]); + const b = await hashVersionCards([card1, card2]); + expect(a).toBe(b); + }); + + it('changes when card order changes', async () => { + const original = await hashVersionCards([card1, card2]); + const swapped = await hashVersionCards([ + { ...card1, ord: 1 }, + { ...card2, ord: 0 }, + ]); + expect(original).not.toBe(swapped); + }); + + it('changes when a field changes', async () => { + const original = await hashVersionCards([card1, card2]); + const tweaked = await hashVersionCards([ + card1, + { ...card2, fields: { ...card2.fields, back: 'A2-edited' } }, + ]); + expect(original).not.toBe(tweaked); + }); + + it('input order independent (sorts by ord)', async () => { + const inOrder = await hashVersionCards([card1, card2]); + const reversedInput = await hashVersionCards([card2, card1]); + expect(inOrder).toBe(reversedInput); + }); + + it('uses cardContentHash for per-card identity (consumes @cards/domain SoT)', async () => { + // Smoke: changing fields without changing the type must change the + // per-card hash (cardContentHash semantics) and therefore the + // version hash. + const a = await cardContentHash({ type: 'basic', fields: { front: 'X', back: 'Y' } }); + const b = await cardContentHash({ type: 'basic', fields: { front: 'X', back: 'Y2' } }); + expect(a).not.toBe(b); + }); +}); diff --git a/docs/marketplace/archive/code/config.ts b/docs/marketplace/archive/code/config.ts new file mode 100644 index 0000000..1e5fb6c --- /dev/null +++ b/docs/marketplace/archive/code/config.ts @@ -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), + }, + }; +} diff --git a/docs/marketplace/archive/code/index.ts b/docs/marketplace/archive/code/index.ts new file mode 100644 index 0000000..8e98754 --- /dev/null +++ b/docs/marketplace/archive/code/index.ts @@ -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, +}; diff --git a/docs/marketplace/archive/code/lib/ai-moderation.ts b/docs/marketplace/archive/code/lib/ai-moderation.ts new file mode 100644 index 0000000..1c9696d --- /dev/null +++ b/docs/marketplace/archive/code/lib/ai-moderation.ts @@ -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 }[]; +} + +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 { + 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 }; +} diff --git a/docs/marketplace/archive/code/lib/credits.ts b/docs/marketplace/archive/code/lib/credits.ts new file mode 100644 index 0000000..864f9dd --- /dev/null +++ b/docs/marketplace/archive/code/lib/credits.ts @@ -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; + refundReservation(input: { reservationId: string }): Promise; + 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(path: string, body: unknown): Promise { + 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), + }; +} diff --git a/docs/marketplace/archive/code/lib/errors.ts b/docs/marketplace/archive/code/lib/errors.ts new file mode 100644 index 0000000..f013b35 --- /dev/null +++ b/docs/marketplace/archive/code/lib/errors.ts @@ -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 }; diff --git a/docs/marketplace/archive/code/lib/hash.ts b/docs/marketplace/archive/code/lib/hash.ts new file mode 100644 index 0000000..4641d1f --- /dev/null +++ b/docs/marketplace/archive/code/lib/hash.ts @@ -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 = {}; + for (const key of Object.keys(value as Record).sort()) { + sorted[key] = canonical((value as Record)[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 { + 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; 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 }))) + ); +} diff --git a/docs/marketplace/archive/code/lib/notify.ts b/docs/marketplace/archive/code/lib/notify.ts new file mode 100644 index 0000000..4d8e8fd --- /dev/null +++ b/docs/marketplace/archive/code/lib/notify.ts @@ -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; + externalId?: string; +} + +interface NotifyClient { + send(input: SendInput): Promise; +} + +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 }; diff --git a/docs/marketplace/archive/code/lib/slug.ts b/docs/marketplace/archive/code/lib/slug.ts new file mode 100644 index 0000000..009cfcb --- /dev/null +++ b/docs/marketplace/archive/code/lib/slug.ts @@ -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 }; +} diff --git a/docs/marketplace/archive/code/middleware/jwt-auth.ts b/docs/marketplace/archive/code/middleware/jwt-auth.ts new file mode 100644 index 0000000..3f12a4b --- /dev/null +++ b/docs/marketplace/archive/code/middleware/jwt-auth.ts @@ -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 | 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'); + } + }; +} diff --git a/docs/marketplace/archive/code/middleware/optional-auth.ts b/docs/marketplace/archive/code/middleware/optional-auth.ts new file mode 100644 index 0000000..f8c31b3 --- /dev/null +++ b/docs/marketplace/archive/code/middleware/optional-auth.ts @@ -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 | 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(); + }; +} diff --git a/docs/marketplace/archive/code/middleware/service-auth.ts b/docs/marketplace/archive/code/middleware/service-auth.ts new file mode 100644 index 0000000..16ccace --- /dev/null +++ b/docs/marketplace/archive/code/middleware/service-auth.ts @@ -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(); + }; +} diff --git a/docs/marketplace/archive/code/routes/authors.ts b/docs/marketplace/archive/code/routes/authors.ts new file mode 100644 index 0000000..f5172c9 --- /dev/null +++ b/docs/marketplace/archive/code/routes/authors.ts @@ -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; +} diff --git a/docs/marketplace/archive/code/routes/decks.ts b/docs/marketplace/archive/code/routes/decks.ts new file mode 100644 index 0000000..87fea50 --- /dev/null +++ b/docs/marketplace/archive/code/routes/decks.ts @@ -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; +} diff --git a/docs/marketplace/archive/code/routes/discussions.ts b/docs/marketplace/archive/code/routes/discussions.ts new file mode 100644 index 0000000..67370ee --- /dev/null +++ b/docs/marketplace/archive/code/routes/discussions.ts @@ -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; +} diff --git a/docs/marketplace/archive/code/routes/engagement.ts b/docs/marketplace/archive/code/routes/engagement.ts new file mode 100644 index 0000000..822f250 --- /dev/null +++ b/docs/marketplace/archive/code/routes/engagement.ts @@ -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; +} diff --git a/docs/marketplace/archive/code/routes/explore.ts b/docs/marketplace/archive/code/routes/explore.ts new file mode 100644 index 0000000..f09c87e --- /dev/null +++ b/docs/marketplace/archive/code/routes/explore.ts @@ -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; +} diff --git a/docs/marketplace/archive/code/routes/moderation.ts b/docs/marketplace/archive/code/routes/moderation.ts new file mode 100644 index 0000000..b5fae51 --- /dev/null +++ b/docs/marketplace/archive/code/routes/moderation.ts @@ -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; +} diff --git a/docs/marketplace/archive/code/routes/pull-requests.ts b/docs/marketplace/archive/code/routes/pull-requests.ts new file mode 100644 index 0000000..943b0da --- /dev/null +++ b/docs/marketplace/archive/code/routes/pull-requests.ts @@ -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; +} diff --git a/docs/marketplace/archive/code/routes/purchases.ts b/docs/marketplace/archive/code/routes/purchases.ts new file mode 100644 index 0000000..56e9a34 --- /dev/null +++ b/docs/marketplace/archive/code/routes/purchases.ts @@ -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; +} diff --git a/docs/marketplace/archive/code/routes/subscriptions.ts b/docs/marketplace/archive/code/routes/subscriptions.ts new file mode 100644 index 0000000..a80257f --- /dev/null +++ b/docs/marketplace/archive/code/routes/subscriptions.ts @@ -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= required, e.g. ?from=1.0.0'); + } + const diff = await service.diffSince(c.req.param('slug'), from); + return c.json(diff); + }); + + return router; +} diff --git a/docs/marketplace/archive/code/services/authors.ts b/docs/marketplace/archive/code/services/authors.ts new file mode 100644 index 0000000..bdc3195 --- /dev/null +++ b/docs/marketplace/archive/code/services/authors.ts @@ -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; + } +} diff --git a/docs/marketplace/archive/code/services/decks.ts b/docs/marketplace/archive/code/services/decks.ts new file mode 100644 index 0000000..6d5d53d --- /dev/null +++ b/docs/marketplace/archive/code/services/decks.ts @@ -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; + }[]; +} + +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 { + 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; diff --git a/docs/marketplace/archive/code/services/discussions.ts b/docs/marketplace/archive/code/services/discussions.ts new file mode 100644 index 0000000..f7521b7 --- /dev/null +++ b/docs/marketplace/archive/code/services/discussions.ts @@ -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> { + 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`count(*)::int`.as('count'), + }) + .from(cardDiscussions) + .where(and(eq(cardDiscussions.deckId, deck.id), eq(cardDiscussions.hidden, false))) + .groupBy(cardDiscussions.cardContentHash); + + const out: Record = {}; + 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)); + } +} diff --git a/docs/marketplace/archive/code/services/engagement.ts b/docs/marketplace/archive/code/services/engagement.ts new file mode 100644 index 0000000..7495479 --- /dev/null +++ b/docs/marketplace/archive/code/services/engagement.ts @@ -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 { + 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 { + 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; + } +} diff --git a/docs/marketplace/archive/code/services/explore.ts b/docs/marketplace/archive/code/services/explore.ts new file mode 100644 index 0000000..f1c846c --- /dev/null +++ b/docs/marketplace/archive/code/services/explore.ts @@ -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`(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`(SELECT count(*)::int FROM cards.deck_stars s WHERE s.deck_id = ${publicDecks.id})`; + const subscriberCount = sql`(SELECT count(*)::int FROM cards.deck_subscriptions s WHERE s.deck_id = ${publicDecks.id})`; + const cardCountExpr = sql`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`(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; + } +} diff --git a/docs/marketplace/archive/code/services/moderation.ts b/docs/marketplace/archive/code/services/moderation.ts new file mode 100644 index 0000000..0cb6a09 --- /dev/null +++ b/docs/marketplace/archive/code/services/moderation.ts @@ -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([ + '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 }; + } +} diff --git a/docs/marketplace/archive/code/services/pull-requests.ts b/docs/marketplace/archive/code/services/pull-requests.ts new file mode 100644 index 0000000..97f679c --- /dev/null +++ b/docs/marketplace/archive/code/services/pull-requests.ts @@ -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 }[]; + modify: { previousContentHash: string; type: string; fields: Record }[]; + 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 { + 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 { + 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 }[]; + modify: { contentHash: string; fields: Record }[]; + 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; 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), + 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 }; + } +} diff --git a/docs/marketplace/archive/code/services/purchases.ts b/docs/marketplace/archive/code/services/purchases.ts new file mode 100644 index 0000000..7e82753 --- /dev/null +++ b/docs/marketplace/archive/code/services/purchases.ts @@ -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 { + 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; + } +} diff --git a/docs/marketplace/archive/code/services/subscriptions.ts b/docs/marketplace/archive/code/services/subscriptions.ts new file mode 100644 index 0000000..07b1ae5 --- /dev/null +++ b/docs/marketplace/archive/code/services/subscriptions.ts @@ -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; + 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 { + 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, + ord: c.ord, + })), + }; + } + + /** Smart-merge diff: tell the client what changed since `fromSemver`. */ + async diffSince(deckSlug: string, fromSemver: string): Promise { + 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(); + 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, + ord: c.ord, + }, + }); + } else { + added.push({ + contentHash: c.contentHash, + type: c.type, + fields: c.fields as Record, + 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, + }; + } +}