From a7b62ea8ae427e0bc5ddb54cb71983cf7d686824 Mon Sep 17 00:00:00 2001 From: Till JS Date: Thu, 7 May 2026 16:01:08 +0200 Subject: [PATCH] =?UTF-8?q?feat(cards-server):=20Phase=20=CE=B1=20?= =?UTF-8?q?=E2=80=94=20service=20skeleton=20+=2016-table=20schema?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lays the foundation for the Cards marketplace + community backend per apps/cards/docs/MARKETPLACE_PLAN.md. Phase α scope: skeleton, schema, JWT auth wiring, health endpoint. Routes follow in Phase β. Stack: Hono + Bun + Drizzle + Postgres + jose-JWKS — mirrors the mana-credits service template. Schema: pgSchema('cards') inside mana_platform, 16 tables across six groups in src/db/schema/: - authors.ts: authors, author_follows - decks.ts: decks, deck_versions, deck_cards (with cards_card_type enum mirroring @mana/cards-core; per-card content_hash for smart-merge; CHECK constraint that paid decks must use Cards-Pro-Only-1.0 license) - tags.ts: tag_definitions (hierarchical), deck_tags - engagement.ts: deck_stars, deck_subscriptions, deck_forks - discussions.ts: deck_pull_requests (with diff jsonb + pr_status enum), card_discussions (bound to card_content_hash so threads survive version bumps) - moderation.ts: deck_reports (with category/status enums), ai_moderation_log - credits.ts: deck_purchases (snapshot price + author/mana split), author_payouts Phase λ's co_learn_sessions intentionally not yet here. Service plumbing: - src/index.ts: Hono entry on :3072, /health unauth, /v1 stub - src/config.ts: env loader with author-payout BPS knobs (defaults 80/20 standard, 90/10 verified-mana) and community-verified thresholds - src/middleware/jwt-auth.ts + service-auth.ts: JWKS validation + X-Service-Key check (mirrors mana-credits) - src/lib/errors.ts: HttpError + named subclasses - drizzle.config.ts pointing at mana_platform with schemaFilter:cards - drizzle/0000_*.sql committed so other devs / prod migration path has a reproducible starting point Validated: tsc --noEmit clean, drizzle-kit generate produces 233-line SQL with all 16 tables + 5 enums + indexes. Next (Phase α.4): Dockerfile + docker-compose + cloudflare tunnel route cards-api.mana.how → :3072. Co-Authored-By: Claude Opus 4.7 (1M context) --- pnpm-lock.yaml | 377 ++-- services/cards-server/.gitignore | 5 + services/cards-server/CLAUDE.md | 110 + services/cards-server/README.md | 6 + services/cards-server/drizzle.config.ts | 14 + .../drizzle/0000_condemned_wrecking_crew.sql | 234 ++ .../drizzle/meta/0000_snapshot.json | 1910 +++++++++++++++++ .../cards-server/drizzle/meta/_journal.json | 13 + services/cards-server/package.json | 27 + services/cards-server/src/config.ts | 72 + services/cards-server/src/db/connection.ts | 23 + .../cards-server/src/db/schema/_schema.ts | 10 + .../cards-server/src/db/schema/authors.ts | 66 + .../cards-server/src/db/schema/credits.ts | 63 + services/cards-server/src/db/schema/decks.ts | 126 ++ .../cards-server/src/db/schema/discussions.ts | 79 + .../cards-server/src/db/schema/engagement.ts | 66 + services/cards-server/src/db/schema/index.ts | 13 + .../cards-server/src/db/schema/moderation.ts | 74 + services/cards-server/src/db/schema/tags.ts | 43 + services/cards-server/src/index.ts | 60 + services/cards-server/src/lib/errors.ts | 51 + .../cards-server/src/middleware/jwt-auth.ts | 56 + .../src/middleware/service-auth.ts | 18 + services/cards-server/src/routes/health.ts | 11 + services/cards-server/tsconfig.json | 20 + 26 files changed, 3407 insertions(+), 140 deletions(-) create mode 100644 services/cards-server/.gitignore create mode 100644 services/cards-server/CLAUDE.md create mode 100644 services/cards-server/README.md create mode 100644 services/cards-server/drizzle.config.ts create mode 100644 services/cards-server/drizzle/0000_condemned_wrecking_crew.sql create mode 100644 services/cards-server/drizzle/meta/0000_snapshot.json create mode 100644 services/cards-server/drizzle/meta/_journal.json create mode 100644 services/cards-server/package.json create mode 100644 services/cards-server/src/config.ts create mode 100644 services/cards-server/src/db/connection.ts create mode 100644 services/cards-server/src/db/schema/_schema.ts create mode 100644 services/cards-server/src/db/schema/authors.ts create mode 100644 services/cards-server/src/db/schema/credits.ts create mode 100644 services/cards-server/src/db/schema/decks.ts create mode 100644 services/cards-server/src/db/schema/discussions.ts create mode 100644 services/cards-server/src/db/schema/engagement.ts create mode 100644 services/cards-server/src/db/schema/index.ts create mode 100644 services/cards-server/src/db/schema/moderation.ts create mode 100644 services/cards-server/src/db/schema/tags.ts create mode 100644 services/cards-server/src/index.ts create mode 100644 services/cards-server/src/lib/errors.ts create mode 100644 services/cards-server/src/middleware/jwt-auth.ts create mode 100644 services/cards-server/src/middleware/service-auth.ts create mode 100644 services/cards-server/src/routes/health.ts create mode 100644 services/cards-server/tsconfig.json diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 625553070..98620dd92 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -141,14 +141,14 @@ importers: version: link:../../../../packages/shared-landing-ui astro: specifier: ^5.16.0 - version: 5.18.1(@azure/storage-blob@12.31.0)(@types/node@20.19.39)(ioredis@5.10.1)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) + version: 5.18.1(@azure/storage-blob@12.31.0)(@types/node@20.19.39)(ioredis@5.10.1)(jiti@1.21.7)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) typescript: specifier: ^5.9.2 version: 5.9.3 devDependencies: '@astrojs/tailwind': specifier: ^6.0.2 - version: 6.0.2(astro@5.18.1(@azure/storage-blob@12.31.0)(@types/node@20.19.39)(ioredis@5.10.1)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.3)) + version: 6.0.2(astro@5.18.1(@azure/storage-blob@12.31.0)(@types/node@20.19.39)(ioredis@5.10.1)(jiti@1.21.7)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.3)) '@tailwindcss/typography': specifier: ^0.5.18 version: 0.5.19(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.3)) @@ -157,13 +157,13 @@ importers: version: 20.19.39 eslint: specifier: ^9.0.0 - version: 9.39.4(jiti@2.6.1) + version: 9.39.4(jiti@1.21.7) eslint-config-prettier: specifier: ^9.1.0 - version: 9.1.2(eslint@9.39.4(jiti@2.6.1)) + version: 9.1.2(eslint@9.39.4(jiti@1.21.7)) eslint-plugin-astro: specifier: ^1.0.0 - version: 1.6.0(eslint@9.39.4(jiti@2.6.1)) + version: 1.6.0(eslint@9.39.4(jiti@1.21.7)) prettier: specifier: ^3.6.2 version: 3.8.1 @@ -321,10 +321,10 @@ importers: version: 3.7.2 '@astrojs/tailwind': specifier: ^6.0.0 - version: 6.0.2(astro@5.18.1(@azure/storage-blob@12.31.0)(@types/node@24.12.2)(ioredis@5.10.1)(jiti@1.21.7)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.3)) + version: 6.0.2(astro@5.18.1(@azure/storage-blob@12.31.0)(@types/node@24.12.2)(ioredis@5.10.1)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.3)) astro: specifier: ^5.16.11 - version: 5.18.1(@azure/storage-blob@12.31.0)(@types/node@24.12.2)(ioredis@5.10.1)(jiti@1.21.7)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) + version: 5.18.1(@azure/storage-blob@12.31.0)(@types/node@24.12.2)(ioredis@5.10.1)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) tailwindcss: specifier: ^3.4.17 version: 3.4.19(tsx@4.21.0)(yaml@2.8.3) @@ -2578,6 +2578,34 @@ importers: specifier: ^2.0.0 version: 2.1.9(@types/node@24.12.2)(jsdom@29.0.2(@noble/hashes@2.0.1))(lightningcss@1.32.0)(terser@5.46.1) + services/cards-server: + dependencies: + '@mana/shared-hono': + specifier: workspace:* + version: link:../../packages/shared-hono + drizzle-orm: + specifier: ^0.38.3 + version: 0.38.4(@opentelemetry/api@1.9.1)(@types/pg@8.6.1)(@types/react@19.2.14)(@types/sql.js@1.4.11)(bun-types@1.3.13)(kysely@0.28.15)(postgres@3.4.9)(react@19.2.0)(sql.js@1.14.1) + hono: + specifier: ^4.7.0 + version: 4.12.12 + jose: + specifier: ^6.1.2 + version: 6.2.2 + postgres: + specifier: ^3.4.5 + version: 3.4.9 + zod: + specifier: ^3.24.0 + version: 3.25.76 + devDependencies: + drizzle-kit: + specifier: ^0.30.4 + version: 0.30.6 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + services/mana-ai: dependencies: '@mana/shared-ai': @@ -17341,6 +17369,16 @@ snapshots: transitivePeerDependencies: - ts-node + '@astrojs/tailwind@6.0.2(astro@5.18.1(@azure/storage-blob@12.31.0)(@types/node@20.19.39)(ioredis@5.10.1)(jiti@1.21.7)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.3))': + dependencies: + astro: 5.18.1(@azure/storage-blob@12.31.0)(@types/node@20.19.39)(ioredis@5.10.1)(jiti@1.21.7)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) + autoprefixer: 10.4.27(postcss@8.5.8) + postcss: 8.5.8 + postcss-load-config: 4.0.2(postcss@8.5.8) + tailwindcss: 3.4.19(tsx@4.21.0)(yaml@2.8.3) + transitivePeerDependencies: + - ts-node + '@astrojs/tailwind@6.0.2(astro@5.18.1(@azure/storage-blob@12.31.0)(@types/node@20.19.39)(ioredis@5.10.1)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.3))': dependencies: astro: 5.18.1(@azure/storage-blob@12.31.0)(@types/node@20.19.39)(ioredis@5.10.1)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) @@ -17361,16 +17399,6 @@ snapshots: transitivePeerDependencies: - ts-node - '@astrojs/tailwind@6.0.2(astro@5.18.1(@azure/storage-blob@12.31.0)(@types/node@24.12.2)(ioredis@5.10.1)(jiti@1.21.7)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.3))': - dependencies: - astro: 5.18.1(@azure/storage-blob@12.31.0)(@types/node@24.12.2)(ioredis@5.10.1)(jiti@1.21.7)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) - autoprefixer: 10.4.27(postcss@8.5.8) - postcss: 8.5.8 - postcss-load-config: 4.0.2(postcss@8.5.8) - tailwindcss: 3.4.19(tsx@4.21.0)(yaml@2.8.3) - transitivePeerDependencies: - - ts-node - '@astrojs/tailwind@6.0.2(astro@5.18.1(@azure/storage-blob@12.31.0)(@types/node@24.12.2)(ioredis@5.10.1)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.3))': dependencies: astro: 5.18.1(@azure/storage-blob@12.31.0)(@types/node@24.12.2)(ioredis@5.10.1)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) @@ -19542,6 +19570,11 @@ snapshots: '@esbuild/win32-x64@0.27.7': optional: true + '@eslint-community/eslint-utils@4.9.1(eslint@9.39.4(jiti@1.21.7))': + dependencies: + eslint: 9.39.4(jiti@1.21.7) + eslint-visitor-keys: 3.4.3 + '@eslint-community/eslint-utils@4.9.1(eslint@9.39.4(jiti@2.6.1))': dependencies: eslint: 9.39.4(jiti@2.6.1) @@ -24650,6 +24683,108 @@ snapshots: transitivePeerDependencies: - supports-color + astro@5.18.1(@azure/storage-blob@12.31.0)(@types/node@20.19.39)(ioredis@5.10.1)(jiti@1.21.7)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3): + dependencies: + '@astrojs/compiler': 2.13.1 + '@astrojs/internal-helpers': 0.7.6 + '@astrojs/markdown-remark': 6.3.11 + '@astrojs/telemetry': 3.3.0 + '@capsizecss/unpack': 4.0.0 + '@oslojs/encoding': 1.1.0 + '@rollup/pluginutils': 5.3.0(rollup@4.60.1) + acorn: 8.16.0 + aria-query: 5.3.2 + axobject-query: 4.1.0 + boxen: 8.0.1 + ci-info: 4.4.0 + clsx: 2.1.1 + common-ancestor-path: 1.0.1 + cookie: 1.1.1 + cssesc: 3.0.0 + debug: 4.4.3 + deterministic-object-hash: 2.0.2 + devalue: 5.7.0 + diff: 8.0.4 + dlv: 1.1.3 + dset: 3.1.4 + es-module-lexer: 1.7.0 + esbuild: 0.27.7 + estree-walker: 3.0.3 + flattie: 1.1.1 + fontace: 0.4.1 + github-slugger: 2.0.0 + html-escaper: 3.0.3 + http-cache-semantics: 4.2.0 + import-meta-resolve: 4.2.0 + js-yaml: 4.1.1 + magic-string: 0.30.21 + magicast: 0.5.2 + mrmime: 2.0.1 + neotraverse: 0.6.18 + p-limit: 6.2.0 + p-queue: 8.1.1 + package-manager-detector: 1.6.0 + piccolore: 0.1.3 + picomatch: 4.0.4 + prompts: 2.4.2 + rehype: 13.0.2 + semver: 7.7.4 + shiki: 3.23.0 + smol-toml: 1.6.1 + svgo: 4.0.1 + tinyexec: 1.0.4 + tinyglobby: 0.2.15 + tsconfck: 3.1.6(typescript@5.9.3) + ultrahtml: 1.6.0 + unifont: 0.7.4 + unist-util-visit: 5.1.0 + unstorage: 1.17.5(@azure/storage-blob@12.31.0)(ioredis@5.10.1) + vfile: 6.0.3 + vite: 6.4.2(@types/node@20.19.39)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + vitefu: 1.1.3(vite@6.4.2(@types/node@20.19.39)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + xxhash-wasm: 1.1.0 + yargs-parser: 21.1.1 + yocto-spinner: 0.2.3 + zod: 3.25.76 + zod-to-json-schema: 3.25.2(zod@3.25.76) + zod-to-ts: 1.2.0(typescript@5.9.3)(zod@3.25.76) + optionalDependencies: + sharp: 0.34.5 + transitivePeerDependencies: + - '@azure/app-configuration' + - '@azure/cosmos' + - '@azure/data-tables' + - '@azure/identity' + - '@azure/keyvault-secrets' + - '@azure/storage-blob' + - '@capacitor/preferences' + - '@deno/kv' + - '@netlify/blobs' + - '@planetscale/database' + - '@types/node' + - '@upstash/redis' + - '@vercel/blob' + - '@vercel/functions' + - '@vercel/kv' + - aws4fetch + - db0 + - idb-keyval + - ioredis + - jiti + - less + - lightningcss + - rollup + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - typescript + - uploadthing + - yaml + astro@5.18.1(@azure/storage-blob@12.31.0)(@types/node@20.19.39)(ioredis@5.10.1)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3): dependencies: '@astrojs/compiler': 2.13.1 @@ -24854,108 +24989,6 @@ snapshots: - uploadthing - yaml - astro@5.18.1(@azure/storage-blob@12.31.0)(@types/node@24.12.2)(ioredis@5.10.1)(jiti@1.21.7)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3): - dependencies: - '@astrojs/compiler': 2.13.1 - '@astrojs/internal-helpers': 0.7.6 - '@astrojs/markdown-remark': 6.3.11 - '@astrojs/telemetry': 3.3.0 - '@capsizecss/unpack': 4.0.0 - '@oslojs/encoding': 1.1.0 - '@rollup/pluginutils': 5.3.0(rollup@4.60.1) - acorn: 8.16.0 - aria-query: 5.3.2 - axobject-query: 4.1.0 - boxen: 8.0.1 - ci-info: 4.4.0 - clsx: 2.1.1 - common-ancestor-path: 1.0.1 - cookie: 1.1.1 - cssesc: 3.0.0 - debug: 4.4.3 - deterministic-object-hash: 2.0.2 - devalue: 5.7.0 - diff: 8.0.4 - dlv: 1.1.3 - dset: 3.1.4 - es-module-lexer: 1.7.0 - esbuild: 0.27.7 - estree-walker: 3.0.3 - flattie: 1.1.1 - fontace: 0.4.1 - github-slugger: 2.0.0 - html-escaper: 3.0.3 - http-cache-semantics: 4.2.0 - import-meta-resolve: 4.2.0 - js-yaml: 4.1.1 - magic-string: 0.30.21 - magicast: 0.5.2 - mrmime: 2.0.1 - neotraverse: 0.6.18 - p-limit: 6.2.0 - p-queue: 8.1.1 - package-manager-detector: 1.6.0 - piccolore: 0.1.3 - picomatch: 4.0.4 - prompts: 2.4.2 - rehype: 13.0.2 - semver: 7.7.4 - shiki: 3.23.0 - smol-toml: 1.6.1 - svgo: 4.0.1 - tinyexec: 1.0.4 - tinyglobby: 0.2.15 - tsconfck: 3.1.6(typescript@5.9.3) - ultrahtml: 1.6.0 - unifont: 0.7.4 - unist-util-visit: 5.1.0 - unstorage: 1.17.5(@azure/storage-blob@12.31.0)(ioredis@5.10.1) - vfile: 6.0.3 - vite: 6.4.2(@types/node@24.12.2)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) - vitefu: 1.1.3(vite@6.4.2(@types/node@24.12.2)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) - xxhash-wasm: 1.1.0 - yargs-parser: 21.1.1 - yocto-spinner: 0.2.3 - zod: 3.25.76 - zod-to-json-schema: 3.25.2(zod@3.25.76) - zod-to-ts: 1.2.0(typescript@5.9.3)(zod@3.25.76) - optionalDependencies: - sharp: 0.34.5 - transitivePeerDependencies: - - '@azure/app-configuration' - - '@azure/cosmos' - - '@azure/data-tables' - - '@azure/identity' - - '@azure/keyvault-secrets' - - '@azure/storage-blob' - - '@capacitor/preferences' - - '@deno/kv' - - '@netlify/blobs' - - '@planetscale/database' - - '@types/node' - - '@upstash/redis' - - '@vercel/blob' - - '@vercel/functions' - - '@vercel/kv' - - aws4fetch - - db0 - - idb-keyval - - ioredis - - jiti - - less - - lightningcss - - rollup - - sass - - sass-embedded - - stylus - - sugarss - - supports-color - - terser - - tsx - - typescript - - uploadthing - - yaml - astro@5.18.1(@azure/storage-blob@12.31.0)(@types/node@24.12.2)(ioredis@5.10.1)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3): dependencies: '@astrojs/compiler': 2.13.1 @@ -26783,6 +26816,11 @@ snapshots: eslint: 9.39.4(jiti@2.6.1) semver: 7.7.4 + eslint-compat-utils@0.6.5(eslint@9.39.4(jiti@1.21.7)): + dependencies: + eslint: 9.39.4(jiti@1.21.7) + semver: 7.7.4 + eslint-compat-utils@0.6.5(eslint@9.39.4(jiti@2.6.1)): dependencies: eslint: 9.39.4(jiti@2.6.1) @@ -26792,6 +26830,10 @@ snapshots: dependencies: eslint: 9.39.4(jiti@2.6.1) + eslint-config-prettier@9.1.2(eslint@9.39.4(jiti@1.21.7)): + dependencies: + eslint: 9.39.4(jiti@1.21.7) + eslint-config-prettier@9.1.2(eslint@9.39.4(jiti@2.6.1)): dependencies: eslint: 9.39.4(jiti@2.6.1) @@ -26836,6 +26878,20 @@ snapshots: transitivePeerDependencies: - supports-color + eslint-plugin-astro@1.6.0(eslint@9.39.4(jiti@1.21.7)): + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@1.21.7)) + '@jridgewell/sourcemap-codec': 1.5.5 + '@typescript-eslint/types': 8.58.0 + astro-eslint-parser: 1.4.0 + eslint: 9.39.4(jiti@1.21.7) + eslint-compat-utils: 0.6.5(eslint@9.39.4(jiti@1.21.7)) + globals: 16.5.0 + postcss: 8.5.8 + postcss-selector-parser: 7.1.1 + transitivePeerDependencies: + - supports-color + eslint-plugin-astro@1.6.0(eslint@9.39.4(jiti@2.6.1)): dependencies: '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1)) @@ -27009,6 +27065,47 @@ snapshots: eslint-visitor-keys@5.0.1: {} + eslint@9.39.4(jiti@1.21.7): + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@1.21.7)) + '@eslint-community/regexpp': 4.12.2 + '@eslint/config-array': 0.21.2 + '@eslint/config-helpers': 0.4.2 + '@eslint/core': 0.17.0 + '@eslint/eslintrc': 3.3.5 + '@eslint/js': 9.39.4 + '@eslint/plugin-kit': 0.4.1 + '@humanfs/node': 0.16.7 + '@humanwhocodes/module-importer': 1.0.1 + '@humanwhocodes/retry': 0.4.3 + '@types/estree': 1.0.8 + ajv: 6.14.0 + chalk: 4.1.2 + cross-spawn: 7.0.6 + debug: 4.4.3 + escape-string-regexp: 4.0.0 + eslint-scope: 8.4.0 + eslint-visitor-keys: 4.2.1 + espree: 10.4.0 + esquery: 1.7.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 8.0.0 + find-up: 5.0.0 + glob-parent: 6.0.2 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + json-stable-stringify-without-jsonify: 1.0.1 + lodash.merge: 4.6.2 + minimatch: 3.1.5 + natural-compare: 1.4.0 + optionator: 0.9.4 + optionalDependencies: + jiti: 1.21.7 + transitivePeerDependencies: + - supports-color + eslint@9.39.4(jiti@2.6.1): dependencies: '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1)) @@ -34087,6 +34184,23 @@ snapshots: lightningcss: 1.32.0 terser: 5.46.1 + vite@6.4.2(@types/node@20.19.39)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3): + dependencies: + esbuild: 0.25.12 + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + postcss: 8.5.8 + rollup: 4.60.1 + tinyglobby: 0.2.15 + optionalDependencies: + '@types/node': 20.19.39 + fsevents: 2.3.3 + jiti: 1.21.7 + lightningcss: 1.32.0 + terser: 5.46.1 + tsx: 4.21.0 + yaml: 2.8.3 + vite@6.4.2(@types/node@20.19.39)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3): dependencies: esbuild: 0.25.12 @@ -34121,23 +34235,6 @@ snapshots: tsx: 4.21.0 yaml: 2.8.3 - vite@6.4.2(@types/node@24.12.2)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3): - dependencies: - esbuild: 0.25.12 - fdir: 6.5.0(picomatch@4.0.4) - picomatch: 4.0.4 - postcss: 8.5.8 - rollup: 4.60.1 - tinyglobby: 0.2.15 - optionalDependencies: - '@types/node': 24.12.2 - fsevents: 2.3.3 - jiti: 1.21.7 - lightningcss: 1.32.0 - terser: 5.46.1 - tsx: 4.21.0 - yaml: 2.8.3 - vite@6.4.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3): dependencies: esbuild: 0.25.12 @@ -34155,6 +34252,10 @@ snapshots: tsx: 4.21.0 yaml: 2.8.3 + vitefu@1.1.3(vite@6.4.2(@types/node@20.19.39)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)): + optionalDependencies: + vite: 6.4.2(@types/node@20.19.39)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + vitefu@1.1.3(vite@6.4.2(@types/node@20.19.39)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)): optionalDependencies: vite: 6.4.2(@types/node@20.19.39)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) @@ -34163,10 +34264,6 @@ snapshots: optionalDependencies: vite: 6.4.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) - vitefu@1.1.3(vite@6.4.2(@types/node@24.12.2)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)): - optionalDependencies: - vite: 6.4.2(@types/node@24.12.2)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) - vitefu@1.1.3(vite@6.4.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)): optionalDependencies: vite: 6.4.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) diff --git a/services/cards-server/.gitignore b/services/cards-server/.gitignore new file mode 100644 index 000000000..7d12a55f1 --- /dev/null +++ b/services/cards-server/.gitignore @@ -0,0 +1,5 @@ +node_modules +.env +.env.local +.DS_Store +drizzle/meta/_journal.json.bak diff --git a/services/cards-server/CLAUDE.md b/services/cards-server/CLAUDE.md new file mode 100644 index 000000000..31f09c5b3 --- /dev/null +++ b/services/cards-server/CLAUDE.md @@ -0,0 +1,110 @@ +# cards-server + +Cards Marketplace + Community backend. Owns the published-deck side of +the Cards product (the standalone app at `cards.mana.how` is the +client). Phase α is the data skeleton — schema + bootstrap + JWT auth +in place; routes land progressively in Phase β onwards. + +For the full design rationale, phasing, and contract decisions see +**[`apps/cards/docs/MARKETPLACE_PLAN.md`](../../apps/cards/docs/MARKETPLACE_PLAN.md)**. + +## Tech Stack + +| Layer | Tech | +|-------|------| +| Runtime | Bun | +| Framework | Hono | +| Database | PostgreSQL (`mana_platform.cards.*` schema) + Drizzle ORM | +| Auth | JWT via JWKS from mana-auth (EdDSA, jose) | +| Money | mana-credits — never Stripe directly | + +## Port: 3072 + +## Quick Start + +```bash +# Schema push (writes to local mana_platform DB) +bun run db:push + +# Dev server with watch +bun run dev + +# Type check +bun run type-check +``` + +## Database + +Schema: **`cards`** inside the shared `mana_platform` DB. 17 tables across +six logical groups (matching the source files in `src/db/schema/`): + +| File | Tables | +|------|--------| +| `authors.ts` | `cards.authors`, `cards.author_follows` | +| `decks.ts` | `cards.decks`, `cards.deck_versions`, `cards.deck_cards` | +| `tags.ts` | `cards.tag_definitions`, `cards.deck_tags` | +| `engagement.ts` | `cards.deck_stars`, `cards.deck_subscriptions`, `cards.deck_forks` | +| `discussions.ts` | `cards.deck_pull_requests`, `cards.card_discussions` | +| `moderation.ts` | `cards.deck_reports`, `cards.ai_moderation_log` | +| `credits.ts` | `cards.deck_purchases`, `cards.author_payouts` | + +`co_learn_sessions` (Phase λ) is intentionally not yet in the schema. +Every table is created via `pgSchema('cards')` per the Mana convention. + +## Auth model + +Three middleware: + +- `jwtAuth(authUrl)` — validates Bearer tokens via JWKS. Sets + `c.set('user', { userId, email, role })`. Used on every user-facing + `/v1/*` route. +- `serviceAuth(serviceKey)` — `X-Service-Key` check for service-to- + service calls (e.g. mana-credits-webhook → cards-server). +- (planned) `optionalAuth` — for routes that should respond + differently when the caller is signed-in but never reject anonymous. + +## Phasing (per MARKETPLACE_PLAN §11) + +| Phase | What lands | Where | +|-------|-----------|-------| +| **α** | Skeleton + schema + JWT + health | now | +| β | Author publish flow + AI-mod-first-pass | next | +| γ | Discovery (browse, search, tags, follow) | | +| δ | Subscribe + smart-merge | | +| ε | Pull-requests + discussions | | +| ζ | mana-credits marketplace | | +| η | Moderation + trust | | +| θ | Deep AI (auto-tags, embeddings, audio) | | +| ι | Optimisation + scale | | + +## Environment Variables + +```env +PORT=3072 +DATABASE_URL=postgresql://mana:devpassword@localhost:5432/mana_platform +MANA_AUTH_URL=http://localhost:3001 +MANA_CREDITS_URL=http://localhost:3061 +MANA_LLM_URL=http://localhost:3025 +MANA_MEDIA_URL=http://localhost:3015 +MANA_NOTIFY_URL=http://localhost:3040 +MANA_SERVICE_KEY=dev-service-key +CORS_ORIGINS=http://localhost:5173,http://localhost:5180 + +# Author payout splits (basis points). Defaults: 80/20 standard, +# 90/10 verified-mana. +AUTHOR_PAYOUT_STANDARD_BPS=8000 +AUTHOR_PAYOUT_VERIFIED_BPS=9000 + +# Community-verified auto-thresholds. +COMMUNITY_VERIFY_STARS=500 +COMMUNITY_VERIFY_FEATURED=3 +COMMUNITY_VERIFY_SUBSCRIBERS=200 +``` + +## Critical Rules + +- **Never call Stripe directly.** All money flows through mana-credits. +- **`/v1` is the public contract** — additive-only changes within v1, breaking changes go to `/v2`. +- **Content-hash everything.** Per-card and per-version SHA-256s drive smart-merge, cache invalidation, and trust. +- **Subscribed Decks are unidirectional.** Author → Subscriber. Forks for the bidirectional case. +- **Verification is binary, not numeric.** Two flags (`verified_mana`, `verified_community`), the UI shows badges. Never invent a "trust score". diff --git a/services/cards-server/README.md b/services/cards-server/README.md new file mode 100644 index 000000000..b3bc44959 --- /dev/null +++ b/services/cards-server/README.md @@ -0,0 +1,6 @@ +# cards-server + +Backend for the Cards marketplace. See +[`CLAUDE.md`](./CLAUDE.md) for the technical overview and +[`apps/cards/docs/MARKETPLACE_PLAN.md`](../../apps/cards/docs/MARKETPLACE_PLAN.md) +for the full product plan. diff --git a/services/cards-server/drizzle.config.ts b/services/cards-server/drizzle.config.ts new file mode 100644 index 000000000..f7bc23d35 --- /dev/null +++ b/services/cards-server/drizzle.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from 'drizzle-kit'; + +export default defineConfig({ + schema: './src/db/schema/*.ts', + out: './drizzle', + dialect: 'postgresql', + dbCredentials: { + url: process.env.DATABASE_URL || 'postgresql://mana:devpassword@localhost:5432/mana_platform', + }, + // All Cards-marketplace tables live under the `cards` schema in the + // shared mana_platform DB — keeps the marketplace state next to the + // rest of the per-app data instead of creating yet another DB. + schemaFilter: ['cards'], +}); diff --git a/services/cards-server/drizzle/0000_condemned_wrecking_crew.sql b/services/cards-server/drizzle/0000_condemned_wrecking_crew.sql new file mode 100644 index 000000000..07fb0aa8e --- /dev/null +++ b/services/cards-server/drizzle/0000_condemned_wrecking_crew.sql @@ -0,0 +1,234 @@ +CREATE SCHEMA "cards"; +--> statement-breakpoint +CREATE TYPE "public"."cards_card_type" AS ENUM('basic', 'basic-reverse', 'cloze', 'type-in', 'image-occlusion', 'audio', 'multiple-choice');--> statement-breakpoint +CREATE TYPE "public"."cards_pr_status" AS ENUM('open', 'merged', 'closed', 'rejected');--> statement-breakpoint +CREATE TYPE "public"."cards_ai_mod_verdict" AS ENUM('pass', 'flag', 'block');--> statement-breakpoint +CREATE TYPE "public"."cards_report_category" AS ENUM('spam', 'copyright', 'nsfw', 'misinformation', 'hate', 'other');--> statement-breakpoint +CREATE TYPE "public"."cards_report_status" AS ENUM('open', 'dismissed', 'actioned');--> statement-breakpoint +CREATE TABLE "cards"."author_follows" ( + "follower_user_id" text NOT NULL, + "author_user_id" text NOT NULL, + "since" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "cards"."authors" ( + "user_id" text PRIMARY KEY NOT NULL, + "slug" text NOT NULL, + "display_name" text NOT NULL, + "bio" text, + "avatar_url" text, + "joined_at" timestamp with time zone DEFAULT now() NOT NULL, + "pseudonym" boolean DEFAULT false NOT NULL, + "verified_mana" boolean DEFAULT false NOT NULL, + "verified_community" boolean DEFAULT false NOT NULL, + "banned_at" timestamp with time zone, + "banned_reason" text +); +--> statement-breakpoint +CREATE TABLE "cards"."author_payouts" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "author_user_id" text NOT NULL, + "source_purchase_id" uuid NOT NULL, + "credits_granted" integer NOT NULL, + "credits_grant_id" text, + "granted_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "cards"."deck_purchases" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "buyer_user_id" text NOT NULL, + "deck_id" uuid NOT NULL, + "version_id" uuid NOT NULL, + "price_credits" integer NOT NULL, + "author_share" integer NOT NULL, + "mana_share" integer NOT NULL, + "credits_transaction" text, + "purchased_at" timestamp with time zone DEFAULT now() NOT NULL, + "refunded_at" timestamp with time zone +); +--> statement-breakpoint +CREATE TABLE "cards"."deck_cards" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "version_id" uuid NOT NULL, + "type" "cards_card_type" NOT NULL, + "fields" jsonb NOT NULL, + "ord" integer NOT NULL, + "content_hash" text NOT NULL +); +--> statement-breakpoint +CREATE TABLE "cards"."deck_versions" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "deck_id" uuid NOT NULL, + "semver" text NOT NULL, + "changelog" text, + "content_hash" text NOT NULL, + "card_count" integer NOT NULL, + "published_at" timestamp with time zone DEFAULT now() NOT NULL, + "deprecated_at" timestamp with time zone +); +--> statement-breakpoint +CREATE TABLE "cards"."decks" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "slug" text NOT NULL, + "title" text NOT NULL, + "description" text, + "language" text, + "license" text DEFAULT 'Cards-Personal-Use-1.0' NOT NULL, + "price_credits" integer DEFAULT 0 NOT NULL, + "owner_user_id" text NOT NULL, + "latest_version_id" uuid, + "is_featured" boolean DEFAULT false NOT NULL, + "is_takedown" boolean DEFAULT false NOT NULL, + "takedown_at" timestamp with time zone, + "takedown_reason" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "decks_price_requires_license" CHECK (price_credits = 0 OR license = 'Cards-Pro-Only-1.0') +); +--> statement-breakpoint +CREATE TABLE "cards"."card_discussions" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "card_content_hash" text NOT NULL, + "deck_id" uuid NOT NULL, + "author_user_id" text NOT NULL, + "parent_id" uuid, + "body" text NOT NULL, + "hidden" boolean DEFAULT false NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "cards"."deck_pull_requests" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "deck_id" uuid NOT NULL, + "author_user_id" text NOT NULL, + "status" "cards_pr_status" DEFAULT 'open' NOT NULL, + "title" text NOT NULL, + "body" text, + "diff" jsonb NOT NULL, + "merged_into_version_id" uuid, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "resolved_at" timestamp with time zone +); +--> statement-breakpoint +CREATE TABLE "cards"."deck_forks" ( + "user_id" text NOT NULL, + "source_deck_id" uuid NOT NULL, + "source_version_id" uuid NOT NULL, + "forked_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "cards"."deck_stars" ( + "user_id" text NOT NULL, + "deck_id" uuid NOT NULL, + "starred_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "cards"."deck_subscriptions" ( + "user_id" text NOT NULL, + "deck_id" uuid NOT NULL, + "current_version_id" uuid, + "subscribed_at" timestamp with time zone DEFAULT now() NOT NULL, + "notify_updates" boolean DEFAULT true NOT NULL +); +--> statement-breakpoint +CREATE TABLE "cards"."deck_tags" ( + "deck_id" uuid NOT NULL, + "tag_id" uuid NOT NULL +); +--> statement-breakpoint +CREATE TABLE "cards"."tag_definitions" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "slug" text NOT NULL, + "name" text NOT NULL, + "parent_id" uuid, + "description" text, + "curated" boolean DEFAULT false NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "cards"."ai_moderation_log" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "version_id" uuid NOT NULL, + "verdict" "cards_ai_mod_verdict" NOT NULL, + "categories" text[], + "model" text, + "rationale" text, + "human_reviewed" boolean DEFAULT false NOT NULL, + "human_overrode" boolean DEFAULT false NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "cards"."deck_reports" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "deck_id" uuid NOT NULL, + "version_id" uuid, + "card_content_hash" text, + "reporter_user_id" text NOT NULL, + "category" "cards_report_category" NOT NULL, + "body" text, + "status" "cards_report_status" DEFAULT 'open' NOT NULL, + "resolved_by" text, + "resolved_at" timestamp with time zone, + "resolution_notes" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "cards"."author_follows" ADD CONSTRAINT "author_follows_author_user_id_authors_user_id_fk" FOREIGN KEY ("author_user_id") REFERENCES "cards"."authors"("user_id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "cards"."author_payouts" ADD CONSTRAINT "author_payouts_author_user_id_authors_user_id_fk" FOREIGN KEY ("author_user_id") REFERENCES "cards"."authors"("user_id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "cards"."author_payouts" ADD CONSTRAINT "author_payouts_source_purchase_id_deck_purchases_id_fk" FOREIGN KEY ("source_purchase_id") REFERENCES "cards"."deck_purchases"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "cards"."deck_purchases" ADD CONSTRAINT "deck_purchases_deck_id_decks_id_fk" FOREIGN KEY ("deck_id") REFERENCES "cards"."decks"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "cards"."deck_purchases" ADD CONSTRAINT "deck_purchases_version_id_deck_versions_id_fk" FOREIGN KEY ("version_id") REFERENCES "cards"."deck_versions"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "cards"."deck_cards" ADD CONSTRAINT "deck_cards_version_id_deck_versions_id_fk" FOREIGN KEY ("version_id") REFERENCES "cards"."deck_versions"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "cards"."deck_versions" ADD CONSTRAINT "deck_versions_deck_id_decks_id_fk" FOREIGN KEY ("deck_id") REFERENCES "cards"."decks"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "cards"."decks" ADD CONSTRAINT "decks_owner_user_id_authors_user_id_fk" FOREIGN KEY ("owner_user_id") REFERENCES "cards"."authors"("user_id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "cards"."card_discussions" ADD CONSTRAINT "card_discussions_deck_id_decks_id_fk" FOREIGN KEY ("deck_id") REFERENCES "cards"."decks"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "cards"."deck_pull_requests" ADD CONSTRAINT "deck_pull_requests_deck_id_decks_id_fk" FOREIGN KEY ("deck_id") REFERENCES "cards"."decks"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "cards"."deck_pull_requests" ADD CONSTRAINT "deck_pull_requests_merged_into_version_id_deck_versions_id_fk" FOREIGN KEY ("merged_into_version_id") REFERENCES "cards"."deck_versions"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "cards"."deck_forks" ADD CONSTRAINT "deck_forks_source_deck_id_decks_id_fk" FOREIGN KEY ("source_deck_id") REFERENCES "cards"."decks"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "cards"."deck_forks" ADD CONSTRAINT "deck_forks_source_version_id_deck_versions_id_fk" FOREIGN KEY ("source_version_id") REFERENCES "cards"."deck_versions"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "cards"."deck_stars" ADD CONSTRAINT "deck_stars_deck_id_decks_id_fk" FOREIGN KEY ("deck_id") REFERENCES "cards"."decks"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "cards"."deck_subscriptions" ADD CONSTRAINT "deck_subscriptions_deck_id_decks_id_fk" FOREIGN KEY ("deck_id") REFERENCES "cards"."decks"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "cards"."deck_subscriptions" ADD CONSTRAINT "deck_subscriptions_current_version_id_deck_versions_id_fk" FOREIGN KEY ("current_version_id") REFERENCES "cards"."deck_versions"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "cards"."deck_tags" ADD CONSTRAINT "deck_tags_deck_id_decks_id_fk" FOREIGN KEY ("deck_id") REFERENCES "cards"."decks"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "cards"."deck_tags" ADD CONSTRAINT "deck_tags_tag_id_tag_definitions_id_fk" FOREIGN KEY ("tag_id") REFERENCES "cards"."tag_definitions"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "cards"."ai_moderation_log" ADD CONSTRAINT "ai_moderation_log_version_id_deck_versions_id_fk" FOREIGN KEY ("version_id") REFERENCES "cards"."deck_versions"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "cards"."deck_reports" ADD CONSTRAINT "deck_reports_deck_id_decks_id_fk" FOREIGN KEY ("deck_id") REFERENCES "cards"."decks"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "cards"."deck_reports" ADD CONSTRAINT "deck_reports_version_id_deck_versions_id_fk" FOREIGN KEY ("version_id") REFERENCES "cards"."deck_versions"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +CREATE UNIQUE INDEX "author_follows_pk" ON "cards"."author_follows" USING btree ("follower_user_id","author_user_id");--> statement-breakpoint +CREATE INDEX "author_follows_author_idx" ON "cards"."author_follows" USING btree ("author_user_id");--> statement-breakpoint +CREATE INDEX "author_follows_follower_idx" ON "cards"."author_follows" USING btree ("follower_user_id");--> statement-breakpoint +CREATE UNIQUE INDEX "authors_slug_idx" ON "cards"."authors" USING btree ("slug");--> statement-breakpoint +CREATE INDEX "authors_verified_idx" ON "cards"."authors" USING btree ("verified_mana","verified_community");--> statement-breakpoint +CREATE INDEX "author_payouts_author_idx" ON "cards"."author_payouts" USING btree ("author_user_id");--> statement-breakpoint +CREATE INDEX "author_payouts_purchase_idx" ON "cards"."author_payouts" USING btree ("source_purchase_id");--> statement-breakpoint +CREATE UNIQUE INDEX "deck_purchases_buyer_deck_idx" ON "cards"."deck_purchases" USING btree ("buyer_user_id","deck_id");--> statement-breakpoint +CREATE INDEX "deck_purchases_buyer_idx" ON "cards"."deck_purchases" USING btree ("buyer_user_id");--> statement-breakpoint +CREATE INDEX "deck_purchases_deck_idx" ON "cards"."deck_purchases" USING btree ("deck_id");--> statement-breakpoint +CREATE UNIQUE INDEX "deck_cards_version_ord_idx" ON "cards"."deck_cards" USING btree ("version_id","ord");--> statement-breakpoint +CREATE INDEX "deck_cards_hash_idx" ON "cards"."deck_cards" USING btree ("content_hash");--> statement-breakpoint +CREATE UNIQUE INDEX "deck_versions_deck_semver_idx" ON "cards"."deck_versions" USING btree ("deck_id","semver");--> statement-breakpoint +CREATE INDEX "deck_versions_deck_idx" ON "cards"."deck_versions" USING btree ("deck_id");--> statement-breakpoint +CREATE INDEX "deck_versions_hash_idx" ON "cards"."deck_versions" USING btree ("content_hash");--> statement-breakpoint +CREATE UNIQUE INDEX "decks_slug_idx" ON "cards"."decks" USING btree ("slug");--> statement-breakpoint +CREATE INDEX "decks_owner_idx" ON "cards"."decks" USING btree ("owner_user_id");--> statement-breakpoint +CREATE INDEX "decks_featured_idx" ON "cards"."decks" USING btree ("is_featured");--> statement-breakpoint +CREATE INDEX "card_discussions_hash_idx" ON "cards"."card_discussions" USING btree ("card_content_hash");--> statement-breakpoint +CREATE INDEX "card_discussions_deck_idx" ON "cards"."card_discussions" USING btree ("deck_id");--> statement-breakpoint +CREATE INDEX "card_discussions_parent_idx" ON "cards"."card_discussions" USING btree ("parent_id");--> statement-breakpoint +CREATE INDEX "deck_pull_requests_deck_idx" ON "cards"."deck_pull_requests" USING btree ("deck_id");--> statement-breakpoint +CREATE INDEX "deck_pull_requests_status_idx" ON "cards"."deck_pull_requests" USING btree ("deck_id","status");--> statement-breakpoint +CREATE INDEX "deck_pull_requests_author_idx" ON "cards"."deck_pull_requests" USING btree ("author_user_id");--> statement-breakpoint +CREATE UNIQUE INDEX "deck_forks_pk" ON "cards"."deck_forks" USING btree ("user_id","source_deck_id","source_version_id");--> statement-breakpoint +CREATE INDEX "deck_forks_source_idx" ON "cards"."deck_forks" USING btree ("source_deck_id");--> statement-breakpoint +CREATE UNIQUE INDEX "deck_stars_pk" ON "cards"."deck_stars" USING btree ("user_id","deck_id");--> statement-breakpoint +CREATE INDEX "deck_stars_deck_idx" ON "cards"."deck_stars" USING btree ("deck_id");--> statement-breakpoint +CREATE UNIQUE INDEX "deck_subscriptions_pk" ON "cards"."deck_subscriptions" USING btree ("user_id","deck_id");--> statement-breakpoint +CREATE INDEX "deck_subscriptions_deck_idx" ON "cards"."deck_subscriptions" USING btree ("deck_id");--> statement-breakpoint +CREATE INDEX "deck_subscriptions_user_idx" ON "cards"."deck_subscriptions" USING btree ("user_id");--> statement-breakpoint +CREATE UNIQUE INDEX "deck_tags_pk" ON "cards"."deck_tags" USING btree ("deck_id","tag_id");--> statement-breakpoint +CREATE INDEX "deck_tags_tag_idx" ON "cards"."deck_tags" USING btree ("tag_id");--> statement-breakpoint +CREATE UNIQUE INDEX "tag_definitions_slug_idx" ON "cards"."tag_definitions" USING btree ("slug");--> statement-breakpoint +CREATE INDEX "tag_definitions_parent_idx" ON "cards"."tag_definitions" USING btree ("parent_id");--> statement-breakpoint +CREATE INDEX "ai_moderation_log_version_idx" ON "cards"."ai_moderation_log" USING btree ("version_id");--> statement-breakpoint +CREATE INDEX "ai_moderation_log_verdict_idx" ON "cards"."ai_moderation_log" USING btree ("verdict");--> statement-breakpoint +CREATE INDEX "deck_reports_deck_idx" ON "cards"."deck_reports" USING btree ("deck_id");--> statement-breakpoint +CREATE INDEX "deck_reports_status_idx" ON "cards"."deck_reports" USING btree ("status"); \ No newline at end of file diff --git a/services/cards-server/drizzle/meta/0000_snapshot.json b/services/cards-server/drizzle/meta/0000_snapshot.json new file mode 100644 index 000000000..72616a91f --- /dev/null +++ b/services/cards-server/drizzle/meta/0000_snapshot.json @@ -0,0 +1,1910 @@ +{ + "id": "dc92bce1-ef98-41fa-97f1-0a6d1512bcdb", + "prevId": "00000000-0000-0000-0000-000000000000", + "version": "7", + "dialect": "postgresql", + "tables": { + "cards.author_follows": { + "name": "author_follows", + "schema": "cards", + "columns": { + "follower_user_id": { + "name": "follower_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "author_user_id": { + "name": "author_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "since": { + "name": "since", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "author_follows_pk": { + "name": "author_follows_pk", + "columns": [ + { + "expression": "follower_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "author_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "author_follows_author_idx": { + "name": "author_follows_author_idx", + "columns": [ + { + "expression": "author_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "author_follows_follower_idx": { + "name": "author_follows_follower_idx", + "columns": [ + { + "expression": "follower_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "author_follows_author_user_id_authors_user_id_fk": { + "name": "author_follows_author_user_id_authors_user_id_fk", + "tableFrom": "author_follows", + "tableTo": "authors", + "schemaTo": "cards", + "columnsFrom": ["author_user_id"], + "columnsTo": ["user_id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "cards.authors": { + "name": "authors", + "schema": "cards", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bio": { + "name": "bio", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "pseudonym": { + "name": "pseudonym", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "verified_mana": { + "name": "verified_mana", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "verified_community": { + "name": "verified_community", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "banned_at": { + "name": "banned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "banned_reason": { + "name": "banned_reason", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "authors_slug_idx": { + "name": "authors_slug_idx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "authors_verified_idx": { + "name": "authors_verified_idx", + "columns": [ + { + "expression": "verified_mana", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "verified_community", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "cards.author_payouts": { + "name": "author_payouts", + "schema": "cards", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "author_user_id": { + "name": "author_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_purchase_id": { + "name": "source_purchase_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "credits_granted": { + "name": "credits_granted", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "credits_grant_id": { + "name": "credits_grant_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "granted_at": { + "name": "granted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "author_payouts_author_idx": { + "name": "author_payouts_author_idx", + "columns": [ + { + "expression": "author_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "author_payouts_purchase_idx": { + "name": "author_payouts_purchase_idx", + "columns": [ + { + "expression": "source_purchase_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "author_payouts_author_user_id_authors_user_id_fk": { + "name": "author_payouts_author_user_id_authors_user_id_fk", + "tableFrom": "author_payouts", + "tableTo": "authors", + "schemaTo": "cards", + "columnsFrom": ["author_user_id"], + "columnsTo": ["user_id"], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "author_payouts_source_purchase_id_deck_purchases_id_fk": { + "name": "author_payouts_source_purchase_id_deck_purchases_id_fk", + "tableFrom": "author_payouts", + "tableTo": "deck_purchases", + "schemaTo": "cards", + "columnsFrom": ["source_purchase_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "cards.deck_purchases": { + "name": "deck_purchases", + "schema": "cards", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "buyer_user_id": { + "name": "buyer_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deck_id": { + "name": "deck_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "version_id": { + "name": "version_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "price_credits": { + "name": "price_credits", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "author_share": { + "name": "author_share", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "mana_share": { + "name": "mana_share", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "credits_transaction": { + "name": "credits_transaction", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "purchased_at": { + "name": "purchased_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "refunded_at": { + "name": "refunded_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "deck_purchases_buyer_deck_idx": { + "name": "deck_purchases_buyer_deck_idx", + "columns": [ + { + "expression": "buyer_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deck_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "deck_purchases_buyer_idx": { + "name": "deck_purchases_buyer_idx", + "columns": [ + { + "expression": "buyer_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "deck_purchases_deck_idx": { + "name": "deck_purchases_deck_idx", + "columns": [ + { + "expression": "deck_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "deck_purchases_deck_id_decks_id_fk": { + "name": "deck_purchases_deck_id_decks_id_fk", + "tableFrom": "deck_purchases", + "tableTo": "decks", + "schemaTo": "cards", + "columnsFrom": ["deck_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "deck_purchases_version_id_deck_versions_id_fk": { + "name": "deck_purchases_version_id_deck_versions_id_fk", + "tableFrom": "deck_purchases", + "tableTo": "deck_versions", + "schemaTo": "cards", + "columnsFrom": ["version_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "cards.deck_cards": { + "name": "deck_cards", + "schema": "cards", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "version_id": { + "name": "version_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "cards_card_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "fields": { + "name": "fields", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "ord": { + "name": "ord", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "deck_cards_version_ord_idx": { + "name": "deck_cards_version_ord_idx", + "columns": [ + { + "expression": "version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "ord", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "deck_cards_hash_idx": { + "name": "deck_cards_hash_idx", + "columns": [ + { + "expression": "content_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "deck_cards_version_id_deck_versions_id_fk": { + "name": "deck_cards_version_id_deck_versions_id_fk", + "tableFrom": "deck_cards", + "tableTo": "deck_versions", + "schemaTo": "cards", + "columnsFrom": ["version_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "cards.deck_versions": { + "name": "deck_versions", + "schema": "cards", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "deck_id": { + "name": "deck_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "semver": { + "name": "semver", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "changelog": { + "name": "changelog", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "card_count": { + "name": "card_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deprecated_at": { + "name": "deprecated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "deck_versions_deck_semver_idx": { + "name": "deck_versions_deck_semver_idx", + "columns": [ + { + "expression": "deck_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "semver", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "deck_versions_deck_idx": { + "name": "deck_versions_deck_idx", + "columns": [ + { + "expression": "deck_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "deck_versions_hash_idx": { + "name": "deck_versions_hash_idx", + "columns": [ + { + "expression": "content_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "deck_versions_deck_id_decks_id_fk": { + "name": "deck_versions_deck_id_decks_id_fk", + "tableFrom": "deck_versions", + "tableTo": "decks", + "schemaTo": "cards", + "columnsFrom": ["deck_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "cards.decks": { + "name": "decks", + "schema": "cards", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "language": { + "name": "language", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "license": { + "name": "license", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'Cards-Personal-Use-1.0'" + }, + "price_credits": { + "name": "price_credits", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "latest_version_id": { + "name": "latest_version_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "is_featured": { + "name": "is_featured", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_takedown": { + "name": "is_takedown", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "takedown_at": { + "name": "takedown_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "takedown_reason": { + "name": "takedown_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "decks_slug_idx": { + "name": "decks_slug_idx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "decks_owner_idx": { + "name": "decks_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "decks_featured_idx": { + "name": "decks_featured_idx", + "columns": [ + { + "expression": "is_featured", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "decks_owner_user_id_authors_user_id_fk": { + "name": "decks_owner_user_id_authors_user_id_fk", + "tableFrom": "decks", + "tableTo": "authors", + "schemaTo": "cards", + "columnsFrom": ["owner_user_id"], + "columnsTo": ["user_id"], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "decks_price_requires_license": { + "name": "decks_price_requires_license", + "value": "price_credits = 0 OR license = 'Cards-Pro-Only-1.0'" + } + }, + "isRLSEnabled": false + }, + "cards.card_discussions": { + "name": "card_discussions", + "schema": "cards", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "card_content_hash": { + "name": "card_content_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deck_id": { + "name": "deck_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "author_user_id": { + "name": "author_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hidden": { + "name": "hidden", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "card_discussions_hash_idx": { + "name": "card_discussions_hash_idx", + "columns": [ + { + "expression": "card_content_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "card_discussions_deck_idx": { + "name": "card_discussions_deck_idx", + "columns": [ + { + "expression": "deck_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "card_discussions_parent_idx": { + "name": "card_discussions_parent_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "card_discussions_deck_id_decks_id_fk": { + "name": "card_discussions_deck_id_decks_id_fk", + "tableFrom": "card_discussions", + "tableTo": "decks", + "schemaTo": "cards", + "columnsFrom": ["deck_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "cards.deck_pull_requests": { + "name": "deck_pull_requests", + "schema": "cards", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "deck_id": { + "name": "deck_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "author_user_id": { + "name": "author_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "cards_pr_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "diff": { + "name": "diff", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "merged_into_version_id": { + "name": "merged_into_version_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "resolved_at": { + "name": "resolved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "deck_pull_requests_deck_idx": { + "name": "deck_pull_requests_deck_idx", + "columns": [ + { + "expression": "deck_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "deck_pull_requests_status_idx": { + "name": "deck_pull_requests_status_idx", + "columns": [ + { + "expression": "deck_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "deck_pull_requests_author_idx": { + "name": "deck_pull_requests_author_idx", + "columns": [ + { + "expression": "author_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "deck_pull_requests_deck_id_decks_id_fk": { + "name": "deck_pull_requests_deck_id_decks_id_fk", + "tableFrom": "deck_pull_requests", + "tableTo": "decks", + "schemaTo": "cards", + "columnsFrom": ["deck_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "deck_pull_requests_merged_into_version_id_deck_versions_id_fk": { + "name": "deck_pull_requests_merged_into_version_id_deck_versions_id_fk", + "tableFrom": "deck_pull_requests", + "tableTo": "deck_versions", + "schemaTo": "cards", + "columnsFrom": ["merged_into_version_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "cards.deck_forks": { + "name": "deck_forks", + "schema": "cards", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_deck_id": { + "name": "source_deck_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "source_version_id": { + "name": "source_version_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "forked_at": { + "name": "forked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "deck_forks_pk": { + "name": "deck_forks_pk", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_deck_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "deck_forks_source_idx": { + "name": "deck_forks_source_idx", + "columns": [ + { + "expression": "source_deck_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "deck_forks_source_deck_id_decks_id_fk": { + "name": "deck_forks_source_deck_id_decks_id_fk", + "tableFrom": "deck_forks", + "tableTo": "decks", + "schemaTo": "cards", + "columnsFrom": ["source_deck_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "deck_forks_source_version_id_deck_versions_id_fk": { + "name": "deck_forks_source_version_id_deck_versions_id_fk", + "tableFrom": "deck_forks", + "tableTo": "deck_versions", + "schemaTo": "cards", + "columnsFrom": ["source_version_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "cards.deck_stars": { + "name": "deck_stars", + "schema": "cards", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deck_id": { + "name": "deck_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "starred_at": { + "name": "starred_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "deck_stars_pk": { + "name": "deck_stars_pk", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deck_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "deck_stars_deck_idx": { + "name": "deck_stars_deck_idx", + "columns": [ + { + "expression": "deck_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "deck_stars_deck_id_decks_id_fk": { + "name": "deck_stars_deck_id_decks_id_fk", + "tableFrom": "deck_stars", + "tableTo": "decks", + "schemaTo": "cards", + "columnsFrom": ["deck_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "cards.deck_subscriptions": { + "name": "deck_subscriptions", + "schema": "cards", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deck_id": { + "name": "deck_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "current_version_id": { + "name": "current_version_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "subscribed_at": { + "name": "subscribed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "notify_updates": { + "name": "notify_updates", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + } + }, + "indexes": { + "deck_subscriptions_pk": { + "name": "deck_subscriptions_pk", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deck_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "deck_subscriptions_deck_idx": { + "name": "deck_subscriptions_deck_idx", + "columns": [ + { + "expression": "deck_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "deck_subscriptions_user_idx": { + "name": "deck_subscriptions_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "deck_subscriptions_deck_id_decks_id_fk": { + "name": "deck_subscriptions_deck_id_decks_id_fk", + "tableFrom": "deck_subscriptions", + "tableTo": "decks", + "schemaTo": "cards", + "columnsFrom": ["deck_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "deck_subscriptions_current_version_id_deck_versions_id_fk": { + "name": "deck_subscriptions_current_version_id_deck_versions_id_fk", + "tableFrom": "deck_subscriptions", + "tableTo": "deck_versions", + "schemaTo": "cards", + "columnsFrom": ["current_version_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "cards.deck_tags": { + "name": "deck_tags", + "schema": "cards", + "columns": { + "deck_id": { + "name": "deck_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "tag_id": { + "name": "tag_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "deck_tags_pk": { + "name": "deck_tags_pk", + "columns": [ + { + "expression": "deck_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tag_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "deck_tags_tag_idx": { + "name": "deck_tags_tag_idx", + "columns": [ + { + "expression": "tag_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "deck_tags_deck_id_decks_id_fk": { + "name": "deck_tags_deck_id_decks_id_fk", + "tableFrom": "deck_tags", + "tableTo": "decks", + "schemaTo": "cards", + "columnsFrom": ["deck_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "deck_tags_tag_id_tag_definitions_id_fk": { + "name": "deck_tags_tag_id_tag_definitions_id_fk", + "tableFrom": "deck_tags", + "tableTo": "tag_definitions", + "schemaTo": "cards", + "columnsFrom": ["tag_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "cards.tag_definitions": { + "name": "tag_definitions", + "schema": "cards", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "curated": { + "name": "curated", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "tag_definitions_slug_idx": { + "name": "tag_definitions_slug_idx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tag_definitions_parent_idx": { + "name": "tag_definitions_parent_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "cards.ai_moderation_log": { + "name": "ai_moderation_log", + "schema": "cards", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "version_id": { + "name": "version_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "verdict": { + "name": "verdict", + "type": "cards_ai_mod_verdict", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "categories": { + "name": "categories", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rationale": { + "name": "rationale", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "human_reviewed": { + "name": "human_reviewed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "human_overrode": { + "name": "human_overrode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "ai_moderation_log_version_idx": { + "name": "ai_moderation_log_version_idx", + "columns": [ + { + "expression": "version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "ai_moderation_log_verdict_idx": { + "name": "ai_moderation_log_verdict_idx", + "columns": [ + { + "expression": "verdict", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ai_moderation_log_version_id_deck_versions_id_fk": { + "name": "ai_moderation_log_version_id_deck_versions_id_fk", + "tableFrom": "ai_moderation_log", + "tableTo": "deck_versions", + "schemaTo": "cards", + "columnsFrom": ["version_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "cards.deck_reports": { + "name": "deck_reports", + "schema": "cards", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "deck_id": { + "name": "deck_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "version_id": { + "name": "version_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "card_content_hash": { + "name": "card_content_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reporter_user_id": { + "name": "reporter_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "category": { + "name": "category", + "type": "cards_report_category", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "cards_report_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "resolved_by": { + "name": "resolved_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resolved_at": { + "name": "resolved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "resolution_notes": { + "name": "resolution_notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "deck_reports_deck_idx": { + "name": "deck_reports_deck_idx", + "columns": [ + { + "expression": "deck_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "deck_reports_status_idx": { + "name": "deck_reports_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "deck_reports_deck_id_decks_id_fk": { + "name": "deck_reports_deck_id_decks_id_fk", + "tableFrom": "deck_reports", + "tableTo": "decks", + "schemaTo": "cards", + "columnsFrom": ["deck_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "deck_reports_version_id_deck_versions_id_fk": { + "name": "deck_reports_version_id_deck_versions_id_fk", + "tableFrom": "deck_reports", + "tableTo": "deck_versions", + "schemaTo": "cards", + "columnsFrom": ["version_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.cards_card_type": { + "name": "cards_card_type", + "schema": "public", + "values": [ + "basic", + "basic-reverse", + "cloze", + "type-in", + "image-occlusion", + "audio", + "multiple-choice" + ] + }, + "public.cards_pr_status": { + "name": "cards_pr_status", + "schema": "public", + "values": ["open", "merged", "closed", "rejected"] + }, + "public.cards_ai_mod_verdict": { + "name": "cards_ai_mod_verdict", + "schema": "public", + "values": ["pass", "flag", "block"] + }, + "public.cards_report_category": { + "name": "cards_report_category", + "schema": "public", + "values": ["spam", "copyright", "nsfw", "misinformation", "hate", "other"] + }, + "public.cards_report_status": { + "name": "cards_report_status", + "schema": "public", + "values": ["open", "dismissed", "actioned"] + } + }, + "schemas": { + "cards": "cards" + }, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/services/cards-server/drizzle/meta/_journal.json b/services/cards-server/drizzle/meta/_journal.json new file mode 100644 index 000000000..ea121bb4c --- /dev/null +++ b/services/cards-server/drizzle/meta/_journal.json @@ -0,0 +1,13 @@ +{ + "version": "7", + "dialect": "postgresql", + "entries": [ + { + "idx": 0, + "version": "7", + "when": 1778162392774, + "tag": "0000_condemned_wrecking_crew", + "breakpoints": true + } + ] +} diff --git a/services/cards-server/package.json b/services/cards-server/package.json new file mode 100644 index 000000000..48e761a28 --- /dev/null +++ b/services/cards-server/package.json @@ -0,0 +1,27 @@ +{ + "name": "@mana/cards-server", + "version": "0.1.0", + "private": true, + "type": "module", + "description": "Cards marketplace + community backend. Owns published decks, versions, subscriptions, forks, pull-requests, discussions, moderation, and the credits-based author payout pipeline.", + "scripts": { + "dev": "bun run --watch src/index.ts", + "start": "bun run src/index.ts", + "type-check": "tsc --noEmit", + "db:push": "drizzle-kit push", + "db:generate": "drizzle-kit generate", + "db:studio": "drizzle-kit studio" + }, + "dependencies": { + "@mana/shared-hono": "workspace:*", + "drizzle-orm": "^0.38.3", + "hono": "^4.7.0", + "jose": "^6.1.2", + "postgres": "^3.4.5", + "zod": "^3.24.0" + }, + "devDependencies": { + "drizzle-kit": "^0.30.4", + "typescript": "^5.9.3" + } +} diff --git a/services/cards-server/src/config.ts b/services/cards-server/src/config.ts new file mode 100644 index 000000000..1e5fb6c9a --- /dev/null +++ b/services/cards-server/src/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/services/cards-server/src/db/connection.ts b/services/cards-server/src/db/connection.ts new file mode 100644 index 000000000..bcab2816a --- /dev/null +++ b/services/cards-server/src/db/connection.ts @@ -0,0 +1,23 @@ +import { drizzle } from 'drizzle-orm/postgres-js'; +import postgres from 'postgres'; +import * as schema from './schema'; + +let _db: ReturnType> | null = null; + +/** + * Lazy singleton — caller passes the url, but reuses the pool across + * the lifetime of the process. drizzle-kit cli skips this and opens + * its own connection from drizzle.config.ts. + */ +export function getDb(url: string) { + if (_db) return _db; + const client = postgres(url, { + max: 10, + idle_timeout: 20, + connect_timeout: 10, + }); + _db = drizzle(client, { schema }); + return _db; +} + +export type Database = ReturnType; diff --git a/services/cards-server/src/db/schema/_schema.ts b/services/cards-server/src/db/schema/_schema.ts new file mode 100644 index 000000000..5aa11b6d8 --- /dev/null +++ b/services/cards-server/src/db/schema/_schema.ts @@ -0,0 +1,10 @@ +import { pgSchema } from 'drizzle-orm/pg-core'; + +/** + * All Cards-marketplace tables live under the `cards` Postgres schema + * inside `mana_platform`. This keeps the marketplace next to the rest + * of the per-app data (Mana convention: one schema per product) and + * lets the per-table FKs reference shared tables (e.g. `auth.users`) + * via plain text columns without cross-DB JOINs. + */ +export const cardsSchema = pgSchema('cards'); diff --git a/services/cards-server/src/db/schema/authors.ts b/services/cards-server/src/db/schema/authors.ts new file mode 100644 index 000000000..be2b7c410 --- /dev/null +++ b/services/cards-server/src/db/schema/authors.ts @@ -0,0 +1,66 @@ +/** + * Authors — public-facing identity layer for users who publish decks. + * + * One author row per user that has ever opted into being an author. + * `userId` is a plain text reference to `auth.users.id` (cross-DB, + * no FK at the DB level — the consumer service validates JWTs from + * mana-auth and uses the `sub` claim verbatim). + * + * Verification has two orthogonal axes: + * - `verified_mana`: manually granted by Mana-Verein (teachers, + * professional educators, doctors, etc.). Not earnable. + * - `verified_community`: automatically calculated from engagement + * (≥ X stars across decks, ≥ Y featured decks, ≥ Z subscribers). + * Periodically re-evaluated. + * + * Both axes can be true at once → the UI shows both badges. + */ + +import { boolean, index, text, timestamp, uniqueIndex } from 'drizzle-orm/pg-core'; +import { cardsSchema } from './_schema'; + +export const authors = cardsSchema.table( + 'authors', + { + userId: text('user_id').primaryKey(), + slug: text('slug').notNull(), + displayName: text('display_name').notNull(), + bio: text('bio'), + avatarUrl: text('avatar_url'), + joinedAt: timestamp('joined_at', { withTimezone: true }).defaultNow().notNull(), + // Pseudonym mode: legal name stays hidden, only displayName visible. + pseudonym: boolean('pseudonym').default(false).notNull(), + // Verification flags (see header). + verifiedMana: boolean('verified_mana').default(false).notNull(), + verifiedCommunity: boolean('verified_community').default(false).notNull(), + // Soft-ban: blocked author can no longer publish, existing decks + // stay readable but get a "deactivated" badge. + bannedAt: timestamp('banned_at', { withTimezone: true }), + bannedReason: text('banned_reason'), + }, + (t) => ({ + slugIdx: uniqueIndex('authors_slug_idx').on(t.slug), + verifiedIdx: index('authors_verified_idx').on(t.verifiedMana, t.verifiedCommunity), + }) +); + +/** + * Following relationship between users (followers) and authors. + * Drives the personal activity feed. + */ +export const authorFollows = cardsSchema.table( + 'author_follows', + { + followerUserId: text('follower_user_id').notNull(), + authorUserId: text('author_user_id') + .notNull() + .references(() => authors.userId, { onDelete: 'cascade' }), + since: timestamp('since', { withTimezone: true }).defaultNow().notNull(), + }, + (t) => ({ + // Composite primary key (user, author). + pk: uniqueIndex('author_follows_pk').on(t.followerUserId, t.authorUserId), + authorIdx: index('author_follows_author_idx').on(t.authorUserId), + followerIdx: index('author_follows_follower_idx').on(t.followerUserId), + }) +); diff --git a/services/cards-server/src/db/schema/credits.ts b/services/cards-server/src/db/schema/credits.ts new file mode 100644 index 000000000..ed4341101 --- /dev/null +++ b/services/cards-server/src/db/schema/credits.ts @@ -0,0 +1,63 @@ +/** + * Two-sided marketplace bookkeeping. The actual money lives in + * mana-credits — we just record the deck-purchase event and the + * derived author payout so we can show buyer history, author + * dashboards, and reconcile against the mana-credits ledger. + */ + +import { index, integer, text, timestamp, uniqueIndex, uuid } from 'drizzle-orm/pg-core'; +import { cardsSchema } from './_schema'; +import { authors } from './authors'; +import { publicDecks, publicDeckVersions } from './decks'; + +export const deckPurchases = cardsSchema.table( + 'deck_purchases', + { + id: uuid('id').primaryKey().defaultRandom(), + buyerUserId: text('buyer_user_id').notNull(), + deckId: uuid('deck_id') + .notNull() + .references(() => publicDecks.id, { onDelete: 'restrict' }), + // Snapshot the version at time of purchase — buyer keeps lifetime + // access to all subsequent versions. + versionId: uuid('version_id') + .notNull() + .references(() => publicDeckVersions.id, { onDelete: 'restrict' }), + // Snapshot of the price at the time of purchase. + priceCredits: integer('price_credits').notNull(), + // Pre-computed split (sum equals priceCredits modulo rounding). + authorShare: integer('author_share').notNull(), + manaShare: integer('mana_share').notNull(), + // Reference into mana-credits ledger. + creditsTransaction: text('credits_transaction'), + purchasedAt: timestamp('purchased_at', { withTimezone: true }).defaultNow().notNull(), + refundedAt: timestamp('refunded_at', { withTimezone: true }), + }, + (t) => ({ + // One purchase per buyer per deck — covers lifetime access. + uniqueBuyerDeck: uniqueIndex('deck_purchases_buyer_deck_idx').on(t.buyerUserId, t.deckId), + buyerIdx: index('deck_purchases_buyer_idx').on(t.buyerUserId), + deckIdx: index('deck_purchases_deck_idx').on(t.deckId), + }) +); + +export const authorPayouts = cardsSchema.table( + 'author_payouts', + { + id: uuid('id').primaryKey().defaultRandom(), + authorUserId: text('author_user_id') + .notNull() + .references(() => authors.userId, { onDelete: 'restrict' }), + sourcePurchaseId: uuid('source_purchase_id') + .notNull() + .references(() => deckPurchases.id, { onDelete: 'restrict' }), + creditsGranted: integer('credits_granted').notNull(), + // Reference into mana-credits grant ledger. + creditsGrantId: text('credits_grant_id'), + grantedAt: timestamp('granted_at', { withTimezone: true }).defaultNow().notNull(), + }, + (t) => ({ + authorIdx: index('author_payouts_author_idx').on(t.authorUserId), + purchaseIdx: index('author_payouts_purchase_idx').on(t.sourcePurchaseId), + }) +); diff --git a/services/cards-server/src/db/schema/decks.ts b/services/cards-server/src/db/schema/decks.ts new file mode 100644 index 000000000..d2bafba94 --- /dev/null +++ b/services/cards-server/src/db/schema/decks.ts @@ -0,0 +1,126 @@ +/** + * Decks, Versions, Cards. + * + * A deck is the long-lived thing the user identifies with ("Spanish + * A2 Vocab"). It always points at a `latest_version_id`. Versions are + * immutable snapshots — once published, they never change. Cards are + * scoped to a version and carry a per-card content-hash so subscriber + * smart-merge can preserve FSRS state for unchanged cards across + * version bumps. + * + * `price_credits` of 0 means free. Anything > 0 forces the + * Cards-Pro-Only-1.0 license (CHECK constraint enforced at DB level). + */ + +import { + boolean, + check, + index, + integer, + jsonb, + pgEnum, + text, + timestamp, + uniqueIndex, + uuid, +} from 'drizzle-orm/pg-core'; +import { sql } from 'drizzle-orm'; +import { cardsSchema } from './_schema'; +import { authors } from './authors'; + +/** Mirrors `CardType` in @mana/cards-core. Phase-1 ships basic / basic-reverse / cloze / type-in. */ +export const cardTypeEnum = pgEnum('cards_card_type', [ + 'basic', + 'basic-reverse', + 'cloze', + 'type-in', + 'image-occlusion', + 'audio', + 'multiple-choice', +]); + +export const publicDecks = cardsSchema.table( + 'decks', + { + id: uuid('id').primaryKey().defaultRandom(), + slug: text('slug').notNull(), + title: text('title').notNull(), + description: text('description'), + // ISO-639-1 (e.g. 'de', 'en', 'es'). Nullable for mixed-language decks. + language: text('language'), + // SPDX-style ID. CC0-1.0, CC-BY-4.0, CC-BY-SA-4.0, + // Cards-Personal-Use-1.0 (default for free), Cards-Pro-Only-1.0 (paid). + license: text('license').notNull().default('Cards-Personal-Use-1.0'), + priceCredits: integer('price_credits').notNull().default(0), + ownerUserId: text('owner_user_id') + .notNull() + .references(() => authors.userId, { onDelete: 'restrict' }), + // Updated each time a new version is published. + latestVersionId: uuid('latest_version_id'), + isFeatured: boolean('is_featured').notNull().default(false), + isTakedown: boolean('is_takedown').notNull().default(false), + takedownAt: timestamp('takedown_at', { withTimezone: true }), + takedownReason: text('takedown_reason'), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), + }, + (t) => ({ + slugIdx: uniqueIndex('decks_slug_idx').on(t.slug), + ownerIdx: index('decks_owner_idx').on(t.ownerUserId), + featuredIdx: index('decks_featured_idx').on(t.isFeatured), + // Paid decks must carry the Pro-Only license. + priceLicense: check( + 'decks_price_requires_license', + sql`price_credits = 0 OR license = 'Cards-Pro-Only-1.0'` + ), + }) +); + +export const publicDeckVersions = cardsSchema.table( + 'deck_versions', + { + id: uuid('id').primaryKey().defaultRandom(), + deckId: uuid('deck_id') + .notNull() + .references(() => publicDecks.id, { onDelete: 'cascade' }), + // SemVer string — ordering done in app code, not DB. + semver: text('semver').notNull(), + changelog: text('changelog'), + // SHA-256 over the canonicalized card list — clients use this to + // detect "did anything change" without diffing payloads. + contentHash: text('content_hash').notNull(), + cardCount: integer('card_count').notNull(), + publishedAt: timestamp('published_at', { withTimezone: true }).defaultNow().notNull(), + // Older versions stay readable but new subscribers go to latest. + deprecatedAt: timestamp('deprecated_at', { withTimezone: true }), + }, + (t) => ({ + uniqueSemver: uniqueIndex('deck_versions_deck_semver_idx').on(t.deckId, t.semver), + deckIdx: index('deck_versions_deck_idx').on(t.deckId), + hashIdx: index('deck_versions_hash_idx').on(t.contentHash), + }) +); + +export const publicDeckCards = cardsSchema.table( + 'deck_cards', + { + id: uuid('id').primaryKey().defaultRandom(), + versionId: uuid('version_id') + .notNull() + .references(() => publicDeckVersions.id, { onDelete: 'cascade' }), + // Mirrors @mana/cards-core CardType. + type: cardTypeEnum('type').notNull(), + // Free-form key/value bag of user content. + // basic / basic-reverse / type-in: { front, back } + // cloze: { text, extra? } + fields: jsonb('fields').$type>().notNull(), + ord: integer('ord').notNull(), + // SHA-256 over canonical(type, fields). Subscribers use this to + // detect per-card changes during smart-merge — unchanged cards + // keep their FSRS state across version pulls. + contentHash: text('content_hash').notNull(), + }, + (t) => ({ + uniqueOrd: uniqueIndex('deck_cards_version_ord_idx').on(t.versionId, t.ord), + hashIdx: index('deck_cards_hash_idx').on(t.contentHash), + }) +); diff --git a/services/cards-server/src/db/schema/discussions.ts b/services/cards-server/src/db/schema/discussions.ts new file mode 100644 index 000000000..6c6e7e6ff --- /dev/null +++ b/services/cards-server/src/db/schema/discussions.ts @@ -0,0 +1,79 @@ +/** + * Pull-Requests + Card-Discussions. + * + * Pull-requests propose card-level changes to a deck; the author can + * merge → cards-server creates a new version automatically. The diff + * is stored as a JSON blob ({ add, modify, remove }) so we can render + * a GitHub-style review UI without re-deriving from version diffs. + * + * Card discussions are bound to `card_content_hash` (not `card_id`) + * so threads survive version bumps as long as the card itself stays + * unchanged. + */ + +import { index, jsonb, text, timestamp, uuid, boolean, pgEnum } from 'drizzle-orm/pg-core'; +import { cardsSchema } from './_schema'; +import { publicDecks, publicDeckVersions } from './decks'; + +export const pullRequestStatusEnum = pgEnum('cards_pr_status', [ + 'open', + 'merged', + 'closed', + 'rejected', +]); + +export interface PullRequestDiff { + add: { type: string; fields: Record }[]; + modify: { contentHash: string; fields: Record }[]; + remove: { contentHash: string }[]; +} + +export const deckPullRequests = cardsSchema.table( + 'deck_pull_requests', + { + id: uuid('id').primaryKey().defaultRandom(), + deckId: uuid('deck_id') + .notNull() + .references(() => publicDecks.id, { onDelete: 'cascade' }), + authorUserId: text('author_user_id').notNull(), + status: pullRequestStatusEnum('status').notNull().default('open'), + title: text('title').notNull(), + body: text('body'), + diff: jsonb('diff').$type().notNull(), + mergedIntoVersionId: uuid('merged_into_version_id').references(() => publicDeckVersions.id, { + onDelete: 'set null', + }), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), + resolvedAt: timestamp('resolved_at', { withTimezone: true }), + }, + (t) => ({ + deckIdx: index('deck_pull_requests_deck_idx').on(t.deckId), + statusIdx: index('deck_pull_requests_status_idx').on(t.deckId, t.status), + authorIdx: index('deck_pull_requests_author_idx').on(t.authorUserId), + }) +); + +export const cardDiscussions = cardsSchema.table( + 'card_discussions', + { + id: uuid('id').primaryKey().defaultRandom(), + // Bound to the card's content_hash, not its row id, so the thread + // follows the card across version bumps as long as content stays. + cardContentHash: text('card_content_hash').notNull(), + deckId: uuid('deck_id') + .notNull() + .references(() => publicDecks.id, { onDelete: 'cascade' }), + authorUserId: text('author_user_id').notNull(), + // Threading: parent_id NULL = root post, NOT NULL = reply. + parentId: uuid('parent_id'), + body: text('body').notNull(), + // Hidden by author or moderator. Not deleted — preserves audit trail. + hidden: boolean('hidden').notNull().default(false), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), + }, + (t) => ({ + hashIdx: index('card_discussions_hash_idx').on(t.cardContentHash), + deckIdx: index('card_discussions_deck_idx').on(t.deckId), + parentIdx: index('card_discussions_parent_idx').on(t.parentId), + }) +); diff --git a/services/cards-server/src/db/schema/engagement.ts b/services/cards-server/src/db/schema/engagement.ts new file mode 100644 index 000000000..21a1d0f10 --- /dev/null +++ b/services/cards-server/src/db/schema/engagement.ts @@ -0,0 +1,66 @@ +/** + * Engagement primitives — Stars (bookmarks), Subscriptions (live + * updates), Forks (own copy with lineage). + * + * All keyed by `user_id` text — a plain reference to auth.users.id + * with no cross-DB FK. + */ + +import { boolean, index, timestamp, uniqueIndex, uuid, text } from 'drizzle-orm/pg-core'; +import { cardsSchema } from './_schema'; +import { publicDecks, publicDeckVersions } from './decks'; + +export const deckStars = cardsSchema.table( + 'deck_stars', + { + userId: text('user_id').notNull(), + deckId: uuid('deck_id') + .notNull() + .references(() => publicDecks.id, { onDelete: 'cascade' }), + starredAt: timestamp('starred_at', { withTimezone: true }).defaultNow().notNull(), + }, + (t) => ({ + pk: uniqueIndex('deck_stars_pk').on(t.userId, t.deckId), + deckIdx: index('deck_stars_deck_idx').on(t.deckId), + }) +); + +export const deckSubscriptions = cardsSchema.table( + 'deck_subscriptions', + { + userId: text('user_id').notNull(), + deckId: uuid('deck_id') + .notNull() + .references(() => publicDecks.id, { onDelete: 'cascade' }), + // Latest version the user has pulled. Smart-merge compares this to + // the deck's `latest_version_id` to compute the diff. + currentVersionId: uuid('current_version_id').references(() => publicDeckVersions.id, { + onDelete: 'set null', + }), + subscribedAt: timestamp('subscribed_at', { withTimezone: true }).defaultNow().notNull(), + notifyUpdates: boolean('notify_updates').notNull().default(true), + }, + (t) => ({ + pk: uniqueIndex('deck_subscriptions_pk').on(t.userId, t.deckId), + deckIdx: index('deck_subscriptions_deck_idx').on(t.deckId), + userIdx: index('deck_subscriptions_user_idx').on(t.userId), + }) +); + +export const deckForks = cardsSchema.table( + 'deck_forks', + { + userId: text('user_id').notNull(), + sourceDeckId: uuid('source_deck_id') + .notNull() + .references(() => publicDecks.id, { onDelete: 'cascade' }), + sourceVersionId: uuid('source_version_id') + .notNull() + .references(() => publicDeckVersions.id, { onDelete: 'cascade' }), + forkedAt: timestamp('forked_at', { withTimezone: true }).defaultNow().notNull(), + }, + (t) => ({ + pk: uniqueIndex('deck_forks_pk').on(t.userId, t.sourceDeckId, t.sourceVersionId), + sourceIdx: index('deck_forks_source_idx').on(t.sourceDeckId), + }) +); diff --git a/services/cards-server/src/db/schema/index.ts b/services/cards-server/src/db/schema/index.ts new file mode 100644 index 000000000..418100d3e --- /dev/null +++ b/services/cards-server/src/db/schema/index.ts @@ -0,0 +1,13 @@ +/** + * Re-exports for the entire cards-server schema. Keep imports flat — + * downstream code does `import { authors, publicDecks } from '../db/schema'`. + */ + +export { cardsSchema } from './_schema'; +export * from './authors'; +export * from './decks'; +export * from './tags'; +export * from './engagement'; +export * from './discussions'; +export * from './moderation'; +export * from './credits'; diff --git a/services/cards-server/src/db/schema/moderation.ts b/services/cards-server/src/db/schema/moderation.ts new file mode 100644 index 000000000..87b320333 --- /dev/null +++ b/services/cards-server/src/db/schema/moderation.ts @@ -0,0 +1,74 @@ +/** + * Moderation — user-submitted reports + AI-first-pass log. + * + * Reports flow into a Mana-admin inbox; AI-mod-log is a record of every + * automated check we ran on a version so we can audit / re-train if a + * bad outcome shipped. + */ + +import { boolean, index, pgEnum, text, timestamp, uuid } from 'drizzle-orm/pg-core'; +import { cardsSchema } from './_schema'; +import { publicDecks, publicDeckVersions } from './decks'; + +export const reportCategoryEnum = pgEnum('cards_report_category', [ + 'spam', + 'copyright', + 'nsfw', + 'misinformation', + 'hate', + 'other', +]); + +export const reportStatusEnum = pgEnum('cards_report_status', ['open', 'dismissed', 'actioned']); + +export const deckReports = cardsSchema.table( + 'deck_reports', + { + id: uuid('id').primaryKey().defaultRandom(), + deckId: uuid('deck_id') + .notNull() + .references(() => publicDecks.id, { onDelete: 'cascade' }), + versionId: uuid('version_id').references(() => publicDeckVersions.id, { + onDelete: 'set null', + }), + // Optional: report scoped to one specific card by content-hash. + cardContentHash: text('card_content_hash'), + reporterUserId: text('reporter_user_id').notNull(), + category: reportCategoryEnum('category').notNull(), + body: text('body'), + status: reportStatusEnum('status').notNull().default('open'), + resolvedBy: text('resolved_by'), + resolvedAt: timestamp('resolved_at', { withTimezone: true }), + resolutionNotes: text('resolution_notes'), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), + }, + (t) => ({ + deckIdx: index('deck_reports_deck_idx').on(t.deckId), + statusIdx: index('deck_reports_status_idx').on(t.status), + }) +); + +export const aiModerationVerdictEnum = pgEnum('cards_ai_mod_verdict', ['pass', 'flag', 'block']); + +export const aiModerationLog = cardsSchema.table( + 'ai_moderation_log', + { + id: uuid('id').primaryKey().defaultRandom(), + versionId: uuid('version_id') + .notNull() + .references(() => publicDeckVersions.id, { onDelete: 'cascade' }), + verdict: aiModerationVerdictEnum('verdict').notNull(), + // Categories the model flagged — array because one verdict can hit + // multiple categories (e.g. "spam" + "misinformation"). + categories: text('categories').array(), + model: text('model'), + rationale: text('rationale'), + humanReviewed: boolean('human_reviewed').notNull().default(false), + humanOverrode: boolean('human_overrode').notNull().default(false), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), + }, + (t) => ({ + versionIdx: index('ai_moderation_log_version_idx').on(t.versionId), + verdictIdx: index('ai_moderation_log_verdict_idx').on(t.verdict), + }) +); diff --git a/services/cards-server/src/db/schema/tags.ts b/services/cards-server/src/db/schema/tags.ts new file mode 100644 index 000000000..d40c1e019 --- /dev/null +++ b/services/cards-server/src/db/schema/tags.ts @@ -0,0 +1,43 @@ +/** + * Tag taxonomy. Hierarchical (parent_id), curated by the Mana-Verein + * for the canonical tree (medizin > anatomie > kardiologie). Authors + * pick from existing tags and can suggest new ones via a moderated + * flow (`curated = false` → admin reviews before promoting). + */ + +import { boolean, index, text, timestamp, uniqueIndex, uuid } from 'drizzle-orm/pg-core'; +import { cardsSchema } from './_schema'; +import { publicDecks } from './decks'; + +export const tagDefinitions = cardsSchema.table( + 'tag_definitions', + { + id: uuid('id').primaryKey().defaultRandom(), + slug: text('slug').notNull(), + name: text('name').notNull(), + parentId: uuid('parent_id'), + description: text('description'), + curated: boolean('curated').notNull().default(false), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), + }, + (t) => ({ + slugIdx: uniqueIndex('tag_definitions_slug_idx').on(t.slug), + parentIdx: index('tag_definitions_parent_idx').on(t.parentId), + }) +); + +export const deckTags = cardsSchema.table( + 'deck_tags', + { + deckId: uuid('deck_id') + .notNull() + .references(() => publicDecks.id, { onDelete: 'cascade' }), + tagId: uuid('tag_id') + .notNull() + .references(() => tagDefinitions.id, { onDelete: 'cascade' }), + }, + (t) => ({ + pk: uniqueIndex('deck_tags_pk').on(t.deckId, t.tagId), + tagIdx: index('deck_tags_tag_idx').on(t.tagId), + }) +); diff --git a/services/cards-server/src/index.ts b/services/cards-server/src/index.ts new file mode 100644 index 000000000..c23b21d65 --- /dev/null +++ b/services/cards-server/src/index.ts @@ -0,0 +1,60 @@ +/** + * 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 { healthRoutes } from './routes/health'; + +// ─── Bootstrap ────────────────────────────────────────────── + +const config = loadConfig(); +// Eager-init the pool so a misconfigured DATABASE_URL fails at boot +// (instead of on the first user request). +getDb(config.databaseUrl); + +// ─── App ──────────────────────────────────────────────────── + +const app = new Hono(); + +app.onError(errorHandler); +app.use( + '*', + cors({ + origin: config.cors.origins, + credentials: true, + }) +); + +// Health (no auth) +app.route('/health', healthRoutes); + +// Versioned API surface — routes will land here in Phase α.3 onwards. +// The /v1 prefix is the public contract from day one (see +// MARKETPLACE_PLAN §3 architecture principle 1). +const v1 = new Hono(); +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); + +// ─── Listen ──────────────────────────────────────────────── + +console.log(`[cards-server] listening on :${config.port}`); +export default { + port: config.port, + fetch: app.fetch, +}; diff --git a/services/cards-server/src/lib/errors.ts b/services/cards-server/src/lib/errors.ts new file mode 100644 index 000000000..cb4a26aee --- /dev/null +++ b/services/cards-server/src/lib/errors.ts @@ -0,0 +1,51 @@ +/** + * Domain errors — caught by serviceErrorHandler from @mana/shared-hono + * and translated to JSON responses with the right status code. + */ + +export class HttpError extends Error { + constructor( + public status: number, + message: string, + public code?: string, + public details?: unknown + ) { + super(message); + this.name = 'HttpError'; + } +} + +export class UnauthorizedError extends HttpError { + constructor(message = 'Unauthorized') { + super(401, message, 'unauthorized'); + this.name = 'UnauthorizedError'; + } +} + +export class ForbiddenError extends HttpError { + constructor(message = 'Forbidden') { + super(403, message, 'forbidden'); + this.name = 'ForbiddenError'; + } +} + +export class NotFoundError extends HttpError { + constructor(message = 'Not found') { + super(404, message, 'not_found'); + this.name = 'NotFoundError'; + } +} + +export class ConflictError extends HttpError { + constructor(message = 'Conflict') { + super(409, message, 'conflict'); + this.name = 'ConflictError'; + } +} + +export class BadRequestError extends HttpError { + constructor(message = 'Bad request', details?: unknown) { + super(400, message, 'bad_request', details); + this.name = 'BadRequestError'; + } +} diff --git a/services/cards-server/src/middleware/jwt-auth.ts b/services/cards-server/src/middleware/jwt-auth.ts new file mode 100644 index 000000000..3f12a4bc6 --- /dev/null +++ b/services/cards-server/src/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/services/cards-server/src/middleware/service-auth.ts b/services/cards-server/src/middleware/service-auth.ts new file mode 100644 index 000000000..16ccace53 --- /dev/null +++ b/services/cards-server/src/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/services/cards-server/src/routes/health.ts b/services/cards-server/src/routes/health.ts new file mode 100644 index 000000000..2613bc64c --- /dev/null +++ b/services/cards-server/src/routes/health.ts @@ -0,0 +1,11 @@ +import { Hono } from 'hono'; + +export const healthRoutes = new Hono(); + +healthRoutes.get('/', (c) => { + return c.json({ + status: 'ok', + service: 'cards-server', + timestamp: new Date().toISOString(), + }); +}); diff --git a/services/cards-server/tsconfig.json b/services/cards-server/tsconfig.json new file mode 100644 index 000000000..a432166c5 --- /dev/null +++ b/services/cards-server/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2022"], + "types": ["bun"], + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "isolatedModules": true, + "verbatimModuleSyntax": true, + "noEmit": true, + "resolveJsonModule": true, + "allowSyntheticDefaultImports": true + }, + "include": ["src/**/*", "drizzle.config.ts"], + "exclude": ["node_modules", "drizzle"] +}