diff --git a/services/cards-server/.gitignore b/services/cards-server/.gitignore deleted file mode 100644 index 7d12a55f1..000000000 --- a/services/cards-server/.gitignore +++ /dev/null @@ -1,5 +0,0 @@ -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 deleted file mode 100644 index 521be6460..000000000 --- a/services/cards-server/CLAUDE.md +++ /dev/null @@ -1,110 +0,0 @@ -# cards-server - -Cardecky Marketplace + Community backend. Owns the published-deck side -of the Cardecky product (the standalone app at `cardecky.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/Dockerfile b/services/cards-server/Dockerfile deleted file mode 100644 index bf217c6a8..000000000 --- a/services/cards-server/Dockerfile +++ /dev/null @@ -1,40 +0,0 @@ -# Install stage: use node + pnpm to resolve workspace dependencies. -# Cards-server is bun-runtime, but pnpm is the only sane way to do -# workspace deps with @mana/shared-hono symlinks. -FROM node:22-alpine AS installer - -RUN corepack enable && corepack prepare pnpm@9.15.0 --activate - -WORKDIR /app - -COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ -COPY services/cards-server/package.json ./services/cards-server/ -COPY packages/shared-hono ./packages/shared-hono -COPY packages/shared-logger ./packages/shared-logger -COPY packages/shared-types ./packages/shared-types - -# Workspace name is `@mana/cards-server`; the trailing `...` includes -# its workspace dependencies. -RUN pnpm install --filter @mana/cards-server... --no-frozen-lockfile --ignore-scripts - -# Runtime stage: bun -FROM oven/bun:1 AS production - -WORKDIR /app - -COPY --from=installer /app/node_modules ./node_modules -COPY --from=installer /app/services/cards-server/node_modules ./services/cards-server/node_modules -COPY --from=installer /app/packages ./packages - -COPY services/cards-server/package.json ./services/cards-server/ -COPY services/cards-server/src ./services/cards-server/src -COPY services/cards-server/tsconfig.json services/cards-server/drizzle.config.ts ./services/cards-server/ - -WORKDIR /app/services/cards-server - -EXPOSE 3072 - -HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \ - CMD bun -e "fetch('http://localhost:3072/health').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))" - -CMD ["bun", "run", "src/index.ts"] diff --git a/services/cards-server/README.md b/services/cards-server/README.md deleted file mode 100644 index b3bc44959..000000000 --- a/services/cards-server/README.md +++ /dev/null @@ -1,6 +0,0 @@ -# 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 deleted file mode 100644 index f7bc23d35..000000000 --- a/services/cards-server/drizzle.config.ts +++ /dev/null @@ -1,14 +0,0 @@ -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 deleted file mode 100644 index 07fb0aa8e..000000000 --- a/services/cards-server/drizzle/0000_condemned_wrecking_crew.sql +++ /dev/null @@ -1,234 +0,0 @@ -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/0001_rename_license_ids_to_cardecky.sql b/services/cards-server/drizzle/0001_rename_license_ids_to_cardecky.sql deleted file mode 100644 index 25ed1db64..000000000 --- a/services/cards-server/drizzle/0001_rename_license_ids_to_cardecky.sql +++ /dev/null @@ -1,23 +0,0 @@ --- Rename SPDX-style license identifiers from `Cards-*` to `Cardecky-*`. --- Folgt dem Brand-Rename des Produkts (Cards → Cardecky, 2026-05-08). --- Phase α (Skelett geshipt 2026-05-07) hat ggf. wenige Datensätze. --- --- Reihenfolge: erst CHECK droppen, dann UPDATE, dann CHECK neu setzen. --- Sonst feuert die alte Constraint beim UPDATE der Pro-Only-Decks. - -ALTER TABLE "cards"."decks" DROP CONSTRAINT "decks_price_requires_license"; - -UPDATE "cards"."decks" -SET "license" = 'Cardecky-Personal-Use-1.0' -WHERE "license" = 'Cards-Personal-Use-1.0'; - -UPDATE "cards"."decks" -SET "license" = 'Cardecky-Pro-Only-1.0' -WHERE "license" = 'Cards-Pro-Only-1.0'; - -ALTER TABLE "cards"."decks" -ALTER COLUMN "license" SET DEFAULT 'Cardecky-Personal-Use-1.0'; - -ALTER TABLE "cards"."decks" -ADD CONSTRAINT "decks_price_requires_license" -CHECK (price_credits = 0 OR license = 'Cardecky-Pro-Only-1.0'); diff --git a/services/cards-server/drizzle/meta/0000_snapshot.json b/services/cards-server/drizzle/meta/0000_snapshot.json deleted file mode 100644 index 72616a91f..000000000 --- a/services/cards-server/drizzle/meta/0000_snapshot.json +++ /dev/null @@ -1,1910 +0,0 @@ -{ - "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/0001_snapshot.json b/services/cards-server/drizzle/meta/0001_snapshot.json deleted file mode 100644 index 97efe503b..000000000 --- a/services/cards-server/drizzle/meta/0001_snapshot.json +++ /dev/null @@ -1,1910 +0,0 @@ -{ - "id": "9b3e7f1c-2a48-4d6b-8c5e-0f1a3d7c8e9b", - "prevId": "dc92bce1-ef98-41fa-97f1-0a6d1512bcdb", - "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": "'Cardecky-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 = 'Cardecky-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 deleted file mode 100644 index d3e78699b..000000000 --- a/services/cards-server/drizzle/meta/_journal.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "version": "7", - "dialect": "postgresql", - "entries": [ - { - "idx": 0, - "version": "7", - "when": 1778162392774, - "tag": "0000_condemned_wrecking_crew", - "breakpoints": true - }, - { - "idx": 1, - "version": "7", - "when": 1746662400000, - "tag": "0001_rename_license_ids_to_cardecky", - "breakpoints": true - } - ] -} diff --git a/services/cards-server/package.json b/services/cards-server/package.json deleted file mode 100644 index d30e408dd..000000000 --- a/services/cards-server/package.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "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 --hot 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 deleted file mode 100644 index 1e5fb6c9a..000000000 --- a/services/cards-server/src/config.ts +++ /dev/null @@ -1,72 +0,0 @@ -/** - * 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 deleted file mode 100644 index bcab2816a..000000000 --- a/services/cards-server/src/db/connection.ts +++ /dev/null @@ -1,23 +0,0 @@ -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 deleted file mode 100644 index 5aa11b6d8..000000000 --- a/services/cards-server/src/db/schema/_schema.ts +++ /dev/null @@ -1,10 +0,0 @@ -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 deleted file mode 100644 index be2b7c410..000000000 --- a/services/cards-server/src/db/schema/authors.ts +++ /dev/null @@ -1,66 +0,0 @@ -/** - * 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 deleted file mode 100644 index ed4341101..000000000 --- a/services/cards-server/src/db/schema/credits.ts +++ /dev/null @@ -1,63 +0,0 @@ -/** - * 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 deleted file mode 100644 index dcdc9f9ba..000000000 --- a/services/cards-server/src/db/schema/decks.ts +++ /dev/null @@ -1,126 +0,0 @@ -/** - * 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 - * Cardecky-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, - // Cardecky-Personal-Use-1.0 (default for free), Cardecky-Pro-Only-1.0 (paid). - license: text('license').notNull().default('Cardecky-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 = 'Cardecky-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 deleted file mode 100644 index 6c6e7e6ff..000000000 --- a/services/cards-server/src/db/schema/discussions.ts +++ /dev/null @@ -1,79 +0,0 @@ -/** - * 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 deleted file mode 100644 index 21a1d0f10..000000000 --- a/services/cards-server/src/db/schema/engagement.ts +++ /dev/null @@ -1,66 +0,0 @@ -/** - * 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 deleted file mode 100644 index 418100d3e..000000000 --- a/services/cards-server/src/db/schema/index.ts +++ /dev/null @@ -1,13 +0,0 @@ -/** - * 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 deleted file mode 100644 index 87b320333..000000000 --- a/services/cards-server/src/db/schema/moderation.ts +++ /dev/null @@ -1,74 +0,0 @@ -/** - * 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 deleted file mode 100644 index d40c1e019..000000000 --- a/services/cards-server/src/db/schema/tags.ts +++ /dev/null @@ -1,43 +0,0 @@ -/** - * 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 deleted file mode 100644 index 8e98754b0..000000000 --- a/services/cards-server/src/index.ts +++ /dev/null @@ -1,140 +0,0 @@ -/** - * cards-server — Cards Marketplace + Community backend. - * - * Hono + Bun. Owns published decks, versions, subscriptions, forks, - * pull-requests, discussions, moderation, and the credits-based - * author payout pipeline. - * - * See apps/cards/docs/MARKETPLACE_PLAN.md for the full design. - */ - -import { Hono } from 'hono'; -import { cors } from 'hono/cors'; -import { serviceErrorHandler as errorHandler } from '@mana/shared-hono'; -import { loadConfig } from './config'; -import { getDb } from './db/connection'; -import { jwtAuth, type AuthUser } from './middleware/jwt-auth'; -import { optionalAuth } from './middleware/optional-auth'; -import { healthRoutes } from './routes/health'; -import { AuthorService } from './services/authors'; -import { DeckService } from './services/decks'; -import { ExploreService } from './services/explore'; -import { EngagementService } from './services/engagement'; -import { SubscriptionService } from './services/subscriptions'; -import { PullRequestService } from './services/pull-requests'; -import { DiscussionService } from './services/discussions'; -import { PurchaseService } from './services/purchases'; -import { ModerationService } from './services/moderation'; -import { createAuthorRoutes } from './routes/authors'; -import { createDeckRoutes } from './routes/decks'; -import { createExploreRoutes } from './routes/explore'; -import { createEngagementRoutes } from './routes/engagement'; -import { createSubscriptionRoutes } from './routes/subscriptions'; -import { createPullRequestRoutes } from './routes/pull-requests'; -import { createDiscussionRoutes } from './routes/discussions'; -import { createPurchaseRoutes } from './routes/purchases'; -import { createModerationRoutes } from './routes/moderation'; -import { createNotifyClient } from './lib/notify'; -import { createCreditsClient } from './lib/credits'; - -// ─── Bootstrap ────────────────────────────────────────────── - -const config = loadConfig(); -const db = getDb(config.databaseUrl); - -const notify = createNotifyClient({ - url: config.manaNotifyUrl, - serviceKey: config.serviceKey, -}); - -const credits = createCreditsClient({ - url: config.manaCreditsUrl, - serviceKey: config.serviceKey, -}); - -const authorService = new AuthorService(db); -const deckService = new DeckService(db, config.manaLlmUrl); -const exploreService = new ExploreService(db); -const engagementService = new EngagementService(db); -const subscriptionService = new SubscriptionService(db); -const pullRequestService = new PullRequestService(db, notify); -const discussionService = new DiscussionService(db); -const purchaseService = new PurchaseService( - db, - credits, - { - standardAuthorBps: config.authorPayout.standardAuthorBps, - verifiedAuthorBps: config.authorPayout.verifiedAuthorBps, - }, - notify -); -const moderationService = new ModerationService(db, notify); - -// ─── App ──────────────────────────────────────────────────── - -const app = new Hono<{ Variables: { user?: AuthUser } }>(); - -app.onError(errorHandler); -app.use( - '*', - cors({ - origin: config.cors.origins, - credentials: true, - }) -); - -// Health (no auth) -app.route('/health', healthRoutes); - -// Versioned API surface — additive-only changes within v1, breaking -// changes go to /v2 (MARKETPLACE_PLAN §3 architecture principle 1). -// -// Two auth tiers: -// - jwtAuth: strict, used on writes (publish, profile updates, -// star/follow). 401 if missing/invalid token. -// - optionalAuth: opportunistic, used on every read. Sets -// c.get('user') if a token validates, otherwise leaves it -// undefined and lets the route serve anonymous content. -const v1 = new Hono<{ Variables: { user?: AuthUser } }>(); - -// Phase γ: public reads first — explore + browse + tags + author -// profile lookup + deck profile lookup. All read-only, no token -// required, but a present token enables logged-in extras (star -// state, follow state) once those flags land in the responses -// (MARKETPLACE_PLAN phase γ.3). -v1.use('/*', optionalAuth(config.manaAuthUrl)); - -// Mounted routers handle their own per-route auth requirements -// via requireUser() helpers when needed. -v1.route('/', createExploreRoutes(exploreService)); -v1.route('/', createEngagementRoutes(engagementService)); -v1.route('/', createSubscriptionRoutes(subscriptionService)); -v1.route('/', createPullRequestRoutes(pullRequestService)); -v1.route('/', createDiscussionRoutes(discussionService)); -v1.route('/', createPurchaseRoutes(purchaseService)); -v1.route('/', createModerationRoutes(moderationService)); -v1.route('/authors', createAuthorRoutes(authorService)); -v1.route('/decks', createDeckRoutes(authorService, deckService, purchaseService)); - -v1.get('/', (c) => - c.json({ - service: 'cards-server', - version: 1, - message: 'See apps/cards/docs/MARKETPLACE_PLAN.md for the full plan.', - }) -); - -app.route('/v1', v1); - -// Keep jwtAuth around — re-exported for callers that need to wrap -// individual mutating subroutes by hand. Not currently used at the -// app-level since we moved to optionalAuth + requireUser per route. -void jwtAuth; - -// ─── Listen ──────────────────────────────────────────────── - -console.log(`[cards-server] listening on :${config.port}`); -export default { - port: config.port, - fetch: app.fetch, -}; diff --git a/services/cards-server/src/lib/ai-moderation.ts b/services/cards-server/src/lib/ai-moderation.ts deleted file mode 100644 index 1c9696d1d..000000000 --- a/services/cards-server/src/lib/ai-moderation.ts +++ /dev/null @@ -1,132 +0,0 @@ -/** - * AI moderation first-pass via mana-llm. - * - * Asks the model to classify a deck's content into one of three - * verdicts: pass, flag, block. `flag` means a human reviewer should - * look at it before the deck goes public; `block` means refuse the - * publish outright. - * - * Per MARKETPLACE_PLAN principle 6: "AI is moderator, not gatekeeper" - * — `block` is only used for unambiguous offences (CSAM, real-world - * doxxing). Anything ambiguous flows to human review. - * - * Fail-open: if mana-llm is unreachable or returns malformed JSON, - * the verdict defaults to `flag` so the human reviewer catches it. - * Better a slow publish than a quietly-skipped check. - */ - -const SYSTEM_PROMPT = `Du bist Inhalts-Moderator für eine Karteikarten-Plattform. Bewerte den vorgelegten Inhalt nach folgenden Kategorien: - -- spam: Werbe-Spam, ohne erkennbaren Lerninhalt -- copyright: offensichtliche, lange Lehrbuch-Auszüge ohne Quelle/Lizenzhinweis -- nsfw: sexuell explizit, jugendgefährdend -- misinformation: nachweislich falsche Fakten als Tatsachen präsentiert (außerhalb subjektiver Themen) -- hate: Hassrede, Diskriminierung gegen geschützte Gruppen -- csam: Material, das Minderjährige sexualisiert (führt IMMER zu block) - -Antworte AUSSCHLIESSLICH mit einem JSON-Objekt: -{"verdict":"pass|flag|block","categories":["..."],"rationale":"kurze Begründung"} - -Regeln: -- pass: keine Kategorien getroffen -- flag: eine oder mehrere Kategorien außer csam -- block: csam ODER unmissverständliche Kombination aus mehreren schweren Kategorien -- Im Zweifel: flag (nicht block) — eine menschliche Moderatorin entscheidet final.`; - -export interface ModerationVerdict { - verdict: 'pass' | 'flag' | 'block'; - categories: string[]; - rationale: string; - model: string; -} - -export interface ModerationInput { - title: string; - description?: string; - cards: { fields: Record }[]; -} - -const MODEL = process.env.AI_MODERATION_MODEL || 'gpt-4o-mini'; -const MAX_CARDS_FOR_PROMPT = 50; - -function buildPrompt(input: ModerationInput): string { - const sample = input.cards.slice(0, MAX_CARDS_FOR_PROMPT).map((c, i) => { - const fieldsStr = Object.entries(c.fields) - .map(([k, v]) => ` ${k}: ${v}`) - .join('\n'); - return `Karte ${i + 1}:\n${fieldsStr}`; - }); - return [ - `Deck-Titel: ${input.title}`, - input.description ? `Beschreibung: ${input.description}` : '', - `Karten (${input.cards.length} insgesamt, erste ${sample.length} gezeigt):`, - ...sample, - ] - .filter(Boolean) - .join('\n\n'); -} - -function failOpen(rationale: string): ModerationVerdict { - return { - verdict: 'flag', - categories: ['_internal'], - rationale: `AI-Mod fail-open: ${rationale}`, - model: MODEL, - }; -} - -function stripCodeFences(s: string): string { - return s - .replace(/^\s*```(?:json)?\s*/i, '') - .replace(/\s*```\s*$/i, '') - .trim(); -} - -export async function moderateDeckContent( - input: ModerationInput, - llmUrl: string -): Promise { - let res: Response; - try { - res = await fetch(`${llmUrl}/v1/chat/completions`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - model: MODEL, - temperature: 0, - messages: [ - { role: 'system', content: SYSTEM_PROMPT }, - { role: 'user', content: buildPrompt(input) }, - ], - }), - }); - } catch (e) { - return failOpen(`network: ${(e as Error).message}`); - } - - if (!res.ok) return failOpen(`http ${res.status}`); - - const json = (await res.json().catch(() => null)) as { - choices?: { message?: { content?: string } }[]; - } | null; - const raw = json?.choices?.[0]?.message?.content?.trim(); - if (!raw) return failOpen('empty response'); - - let parsed: { verdict?: unknown; categories?: unknown; rationale?: unknown }; - try { - parsed = JSON.parse(stripCodeFences(raw)); - } catch { - return failOpen('invalid JSON'); - } - - const verdict = - parsed.verdict === 'pass' || parsed.verdict === 'flag' || parsed.verdict === 'block' - ? parsed.verdict - : 'flag'; - const categories = Array.isArray(parsed.categories) - ? parsed.categories.filter((c): c is string => typeof c === 'string') - : []; - const rationale = typeof parsed.rationale === 'string' ? parsed.rationale : ''; - - return { verdict, categories, rationale, model: MODEL }; -} diff --git a/services/cards-server/src/lib/credits.ts b/services/cards-server/src/lib/credits.ts deleted file mode 100644 index 864f9dd73..000000000 --- a/services/cards-server/src/lib/credits.ts +++ /dev/null @@ -1,80 +0,0 @@ -/** - * Thin client for mana-credits internal API. Cards-server is a - * service-to-service caller — the buyer's JWT does not flow through - * here; we use the X-Service-Key channel instead so we can reserve - * credits on a user's behalf, commit them after the purchase row is - * written, and grant the author share in one server-side flow. - * - * Errors propagate as Error subclasses so the purchase service can - * branch on `InsufficientCredits` vs. infra failures. - */ - -export class CreditsClientError extends Error { - constructor( - public readonly status: number, - public readonly code: string, - message: string - ) { - super(message); - this.name = 'CreditsClientError'; - } -} - -export class InsufficientCreditsError extends CreditsClientError { - constructor(message: string) { - super(402, 'insufficient_credits', message); - this.name = 'InsufficientCreditsError'; - } -} - -export interface CreditsClient { - reserve(input: { userId: string; amount: number; reason: string }): Promise<{ - reservationId: string; - balance: number; - }>; - commit(input: { reservationId: string; description?: string }): Promise; - refundReservation(input: { reservationId: string }): Promise; - grant(input: { - userId: string; - amount: number; - reason: string; - referenceId: string; - description?: string; - }): Promise<{ transactionId?: string; grantId?: string } | unknown>; -} - -export function createCreditsClient(opts: { url: string; serviceKey: string }): CreditsClient { - async function call(path: string, body: unknown): Promise { - const res = await fetch(`${opts.url}/api/v1/internal${path}`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-Service-Key': opts.serviceKey, - }, - body: JSON.stringify(body), - }); - if (!res.ok) { - let msg = `${res.status} ${res.statusText}`; - let code = 'credits_error'; - try { - const j = (await res.json()) as { code?: string; message?: string }; - if (j.message) msg = j.message; - if (j.code) code = j.code; - } catch { - /* keep default */ - } - if (res.status === 402 || code === 'insufficient_credits') { - throw new InsufficientCreditsError(msg); - } - throw new CreditsClientError(res.status, code, msg); - } - return (await res.json()) as T; - } - - return { - reserve: (input) => call('/credits/reserve', input), - commit: (input) => call('/credits/commit', input), - refundReservation: (input) => call('/credits/refund-reservation', input), - grant: (input) => call('/credits/grant', input), - }; -} diff --git a/services/cards-server/src/lib/errors.ts b/services/cards-server/src/lib/errors.ts deleted file mode 100644 index f013b358e..000000000 --- a/services/cards-server/src/lib/errors.ts +++ /dev/null @@ -1,63 +0,0 @@ -/** - * Domain errors — caught by `serviceErrorHandler` from @mana/shared-hono. - * - * The shared handler only translates Hono `HTTPException`s; anything - * else degrades to 500. So our errors extend HTTPException directly - * rather than maintaining a parallel hierarchy. - * - * `details` (e.g. zod issue tree) is passed via `cause` because the - * shared handler picks that up and surfaces it in the JSON body. - */ - -import { HTTPException } from 'hono/http-exception'; -import type { ContentfulStatusCode } from 'hono/utils/http-status'; - -function makeException( - status: ContentfulStatusCode, - message: string, - code?: string, - details?: unknown -) { - return new HTTPException(status, { - message, - cause: details ? { code, details } : code ? { code } : undefined, - }); -} - -export class HttpError extends HTTPException {} - -export class UnauthorizedError extends HTTPException { - constructor(message = 'Unauthorized') { - super(401, { message, cause: { code: 'unauthorized' } }); - } -} - -export class ForbiddenError extends HTTPException { - constructor(message = 'Forbidden') { - super(403, { message, cause: { code: 'forbidden' } }); - } -} - -export class NotFoundError extends HTTPException { - constructor(message = 'Not found') { - super(404, { message, cause: { code: 'not_found' } }); - } -} - -export class ConflictError extends HTTPException { - constructor(message = 'Conflict') { - super(409, { message, cause: { code: 'conflict' } }); - } -} - -export class BadRequestError extends HTTPException { - constructor(message = 'Bad request', details?: unknown) { - super(400, { - message, - cause: details ? { code: 'bad_request', details } : { code: 'bad_request' }, - }); - } -} - -// Keep makeException exported in case future code wants the raw factory. -export { makeException }; diff --git a/services/cards-server/src/lib/hash.ts b/services/cards-server/src/lib/hash.ts deleted file mode 100644 index 4641d1f47..000000000 --- a/services/cards-server/src/lib/hash.ts +++ /dev/null @@ -1,44 +0,0 @@ -/** - * Content hashing — SHA-256 over canonicalized payloads. Drives: - * - per-card `content_hash` (smart-merge across version bumps) - * - per-version `content_hash` (cache + dedup detection) - * - * Canonicalization sorts object keys recursively so `{a:1,b:2}` and - * `{b:2,a:1}` produce identical hashes. Without that, equivalent - * payloads from different clients would diverge. Numbers/booleans - * stringify naturally; strings are passed through verbatim. - */ - -import { createHash } from 'node:crypto'; - -function canonical(value: unknown): unknown { - if (value === null || typeof value !== 'object') return value; - if (Array.isArray(value)) return value.map(canonical); - const sorted: Record = {}; - for (const key of Object.keys(value as Record).sort()) { - sorted[key] = canonical((value as Record)[key]); - } - return sorted; -} - -function sha256(input: string): string { - return createHash('sha256').update(input).digest('hex'); -} - -/** Hash for a single card — based on (type, fields). */ -export function hashCard(card: { type: string; fields: Record }): string { - return sha256(JSON.stringify(canonical({ type: card.type, fields: card.fields }))); -} - -/** - * Hash for an ordered list of cards — version content hash. Order - * matters because re-ordering is a meaningful change for the learner. - */ -export function hashVersionCards( - cards: { type: string; fields: Record; ord: number }[] -): string { - const ordered = [...cards].sort((a, b) => a.ord - b.ord); - return sha256( - JSON.stringify(ordered.map((c) => canonical({ type: c.type, fields: c.fields, ord: c.ord }))) - ); -} diff --git a/services/cards-server/src/lib/notify.ts b/services/cards-server/src/lib/notify.ts deleted file mode 100644 index 4d8e8fd1c..000000000 --- a/services/cards-server/src/lib/notify.ts +++ /dev/null @@ -1,51 +0,0 @@ -/** - * Thin client for mana-notify. Fire-and-forget by design — a failed - * notification must never roll back a domain action (PR merge, etc.), - * so all callers `void` the promise and we just log on failure. - * - * `appId: 'cards'` keeps these notifications grouped in user - * preferences so a learner can mute "PR activity" without losing - * other Mana mail. - */ - -interface SendInput { - channel: 'email' | 'push' | 'webhook'; - userId: string; - subject: string; - body: string; - data?: Record; - externalId?: string; -} - -interface NotifyClient { - send(input: SendInput): Promise; -} - -export function createNotifyClient(opts: { url: string; serviceKey: string }): NotifyClient { - return { - async send(input) { - try { - await fetch(`${opts.url}/api/v1/notifications/send`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-Service-Key': opts.serviceKey, - }, - body: JSON.stringify({ - channel: input.channel, - appId: 'cards', - userId: input.userId, - subject: input.subject, - body: input.body, - data: input.data, - externalId: input.externalId, - }), - }); - } catch (err) { - console.warn('[cards-server] notify failed', err); - } - }, - }; -} - -export type { NotifyClient }; diff --git a/services/cards-server/src/lib/slug.ts b/services/cards-server/src/lib/slug.ts deleted file mode 100644 index 009cfcbf2..000000000 --- a/services/cards-server/src/lib/slug.ts +++ /dev/null @@ -1,58 +0,0 @@ -/** - * URL-safe slug helpers. - * - * `slugify` is best-effort — turns "Anna Lang!" into "anna-lang" — for - * suggesting an initial slug. `validateSlug` is strict and what we - * enforce on every write so the URL space stays predictable. - */ - -const MAX_SLUG_LEN = 60; -const MIN_SLUG_LEN = 3; - -const SLUG_RE = /^[a-z0-9](?:[a-z0-9-]{1,58}[a-z0-9])?$/; - -const RESERVED_SLUGS = new Set([ - 'admin', - 'api', - 'app', - 'auth', - 'docs', - 'explore', - 'feed', - 'help', - 'me', - 'mana', - 'new', - 'public', - 'search', - 'settings', - 'support', - 'system', - 'u', - 'd', - 'v1', - 'v2', -]); - -export function slugify(input: string): string { - return input - .normalize('NFKD') - .replace(/[̀-ͯ]/g, '') // strip diacritics - .toLowerCase() - .replace(/[^a-z0-9]+/g, '-') - .replace(/^-+|-+$/g, '') - .slice(0, MAX_SLUG_LEN); -} - -export interface SlugValidation { - ok: boolean; - reason?: 'too-short' | 'too-long' | 'invalid-chars' | 'reserved'; -} - -export function validateSlug(slug: string): SlugValidation { - if (slug.length < MIN_SLUG_LEN) return { ok: false, reason: 'too-short' }; - if (slug.length > MAX_SLUG_LEN) return { ok: false, reason: 'too-long' }; - if (!SLUG_RE.test(slug)) return { ok: false, reason: 'invalid-chars' }; - if (RESERVED_SLUGS.has(slug)) return { ok: false, reason: 'reserved' }; - return { ok: true }; -} diff --git a/services/cards-server/src/middleware/jwt-auth.ts b/services/cards-server/src/middleware/jwt-auth.ts deleted file mode 100644 index 3f12a4bc6..000000000 --- a/services/cards-server/src/middleware/jwt-auth.ts +++ /dev/null @@ -1,56 +0,0 @@ -/** - * 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/optional-auth.ts b/services/cards-server/src/middleware/optional-auth.ts deleted file mode 100644 index f8c31b35d..000000000 --- a/services/cards-server/src/middleware/optional-auth.ts +++ /dev/null @@ -1,51 +0,0 @@ -/** - * Optional JWT — sets `c.get('user')` when a valid Bearer token is - * present, but never rejects the request. Routes that need an - * authenticated user fall back to `null` and decide what to do - * (most public endpoints just hide private fields; mutation endpoints - * still throw 401 explicitly). - * - * Why a separate middleware? `jwtAuth` is the strict gate for write - * paths — same JWKS, same algo, but rejecting early. `optionalAuth` - * is the read-path companion: it lets cardecky-api.mana.how serve the - * marketplace surface to anonymous browsers (search engines, anti- - * link-rot, share-link previews) while still recognising signed-in - * users for star/follow state. - */ - -import type { MiddlewareHandler } from 'hono'; -import { createRemoteJWKSet, jwtVerify } from 'jose'; -import type { AuthUser } from './jwt-auth'; - -let jwks: ReturnType | null = null; -function getJwks(authUrl: string) { - if (!jwks) jwks = createRemoteJWKSet(new URL('/api/auth/jwks', authUrl)); - return jwks; -} - -export function optionalAuth(authUrl: string): MiddlewareHandler { - return async (c, next) => { - const authHeader = c.req.header('Authorization'); - if (!authHeader?.startsWith('Bearer ')) { - await next(); - return; - } - const token = authHeader.slice(7); - try { - const { payload } = await jwtVerify(token, getJwks(authUrl), { - issuer: authUrl, - audience: 'mana', - }); - const user: AuthUser = { - userId: payload.sub || '', - email: (payload.email as string) || '', - role: (payload.role as string) || 'user', - }; - c.set('user', user); - } catch { - // Bad token = anonymous; the strict middleware rejects on - // write paths. - } - await next(); - }; -} diff --git a/services/cards-server/src/middleware/service-auth.ts b/services/cards-server/src/middleware/service-auth.ts deleted file mode 100644 index 16ccace53..000000000 --- a/services/cards-server/src/middleware/service-auth.ts +++ /dev/null @@ -1,18 +0,0 @@ -/** - * 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/authors.ts b/services/cards-server/src/routes/authors.ts deleted file mode 100644 index f5172c93f..000000000 --- a/services/cards-server/src/routes/authors.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { Hono } from 'hono'; -import { z } from 'zod'; -import type { AuthUser } from '../middleware/jwt-auth'; -import type { AuthorService } from '../services/authors'; -import { BadRequestError, UnauthorizedError } from '../lib/errors'; - -const upsertSchema = z.object({ - slug: z.string(), - displayName: z.string().min(1).max(80), - bio: z.string().max(500).optional(), - avatarUrl: z.string().url().max(512).optional(), - pseudonym: z.boolean().optional(), -}); - -function requireUser(user: AuthUser | undefined): AuthUser { - if (!user || !user.userId) throw new UnauthorizedError(); - return user; -} - -export function createAuthorRoutes(authorService: AuthorService) { - const router = new Hono<{ Variables: { user?: AuthUser } }>(); - - // POST /me + GET /me are write/private — auth required. - router.post('/me', async (c) => { - const user = requireUser(c.get('user')); - const parsed = upsertSchema.safeParse(await c.req.json().catch(() => ({}))); - if (!parsed.success) throw new BadRequestError('Invalid body', parsed.error.format()); - const author = await authorService.upsertMe(user.userId, parsed.data); - return c.json(author); - }); - - router.get('/me', async (c) => { - const user = requireUser(c.get('user')); - const author = await authorService.getByUserId(user.userId); - return c.json(author); - }); - - // GET /:slug is public — anyone can look up an author profile. - router.get('/:slug', async (c) => { - const author = await authorService.getPublicBySlug(c.req.param('slug')); - return c.json(author); - }); - - return router; -} diff --git a/services/cards-server/src/routes/decks.ts b/services/cards-server/src/routes/decks.ts deleted file mode 100644 index 87fea506c..000000000 --- a/services/cards-server/src/routes/decks.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { Hono } from 'hono'; -import { z } from 'zod'; -import type { AuthUser } from '../middleware/jwt-auth'; -import type { AuthorService } from '../services/authors'; -import type { DeckService } from '../services/decks'; -import type { PurchaseService } from '../services/purchases'; -import { BadRequestError, UnauthorizedError } from '../lib/errors'; - -const cardTypes = [ - 'basic', - 'basic-reverse', - 'cloze', - 'type-in', - 'image-occlusion', - 'audio', - 'multiple-choice', -] as const; - -const initSchema = z.object({ - slug: z.string(), - title: z.string().min(1).max(140), - description: z.string().max(2000).optional(), - language: z.string().min(2).max(8).optional(), - license: z.string().max(64).optional(), - priceCredits: z.number().int().min(0).max(10_000).optional(), -}); - -const publishSchema = z.object({ - semver: z.string(), - changelog: z.string().max(2000).optional(), - cards: z - .array( - z.object({ - type: z.enum(cardTypes), - fields: z.record(z.string(), z.string()), - }) - ) - .min(1) - .max(5_000), -}); - -function requireUser(user: AuthUser | undefined): AuthUser { - if (!user || !user.userId) throw new UnauthorizedError(); - return user; -} - -export function createDeckRoutes( - authorService: AuthorService, - deckService: DeckService, - purchaseService?: PurchaseService -) { - const router = new Hono<{ Variables: { user?: AuthUser } }>(); - - // Init = write, auth required. - router.post('/', async (c) => { - const user = requireUser(c.get('user')); - await authorService.assertNotBanned(user.userId); - const parsed = initSchema.safeParse(await c.req.json().catch(() => ({}))); - if (!parsed.success) throw new BadRequestError('Invalid body', parsed.error.format()); - const deck = await deckService.init(user.userId, parsed.data); - return c.json(deck, 201); - }); - - // GET deck-by-slug is public — anyone can preview a deck. If a - // JWT is present we also annotate `hasPurchased` so the buy - // button can be hidden for owners. - router.get('/:slug', async (c) => { - const result = await deckService.getBySlug(c.req.param('slug')); - const user = c.get('user'); - const hasPurchased = - user?.userId && purchaseService - ? await purchaseService.hasPurchased(user.userId, result.deck.id) - : null; - return c.json({ ...result, hasPurchased }); - }); - - router.post('/:slug/publish', async (c) => { - const user = requireUser(c.get('user')); - await authorService.assertNotBanned(user.userId); - const parsed = publishSchema.safeParse(await c.req.json().catch(() => ({}))); - if (!parsed.success) throw new BadRequestError('Invalid body', parsed.error.format()); - const result = await deckService.publish(user.userId, c.req.param('slug'), parsed.data); - return c.json(result, 201); - }); - - return router; -} diff --git a/services/cards-server/src/routes/discussions.ts b/services/cards-server/src/routes/discussions.ts deleted file mode 100644 index 67370ee9e..000000000 --- a/services/cards-server/src/routes/discussions.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { Hono } from 'hono'; -import { z } from 'zod'; -import type { AuthUser } from '../middleware/jwt-auth'; -import type { DiscussionService } from '../services/discussions'; -import { BadRequestError, UnauthorizedError } from '../lib/errors'; - -function requireUser(user: AuthUser | undefined): AuthUser { - if (!user || !user.userId) throw new UnauthorizedError(); - return user; -} - -const postSchema = z.object({ - deckSlug: z.string().min(1), - body: z.string().min(1).max(4000), - parentId: z.string().uuid().optional(), -}); - -export function createDiscussionRoutes(service: DiscussionService) { - const router = new Hono<{ Variables: { user?: AuthUser } }>(); - - router.get('/cards/:contentHash/discussions', async (c) => { - const list = await service.listForCard(c.req.param('contentHash')); - return c.json(list); - }); - - router.get('/decks/:slug/discussion-counts', async (c) => { - const counts = await service.countsForDeck(c.req.param('slug')); - return c.json(counts); - }); - - router.post('/cards/:contentHash/discussions', async (c) => { - const user = requireUser(c.get('user')); - const parsed = postSchema.safeParse(await c.req.json().catch(() => ({}))); - if (!parsed.success) throw new BadRequestError('Invalid body', parsed.error.format()); - const row = await service.post( - user.userId, - parsed.data.deckSlug, - c.req.param('contentHash'), - parsed.data.body, - parsed.data.parentId - ); - return c.json(row, 201); - }); - - router.post('/discussions/:id/hide', async (c) => { - const user = requireUser(c.get('user')); - await service.hide(user.userId, c.req.param('id')); - return c.json({ ok: true }); - }); - - return router; -} diff --git a/services/cards-server/src/routes/engagement.ts b/services/cards-server/src/routes/engagement.ts deleted file mode 100644 index 822f25043..000000000 --- a/services/cards-server/src/routes/engagement.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { Hono } from 'hono'; -import type { AuthUser } from '../middleware/jwt-auth'; -import type { EngagementService } from '../services/engagement'; -import { UnauthorizedError } from '../lib/errors'; - -function requireUser(user: AuthUser | undefined): AuthUser { - if (!user || !user.userId) throw new UnauthorizedError(); - return user; -} - -export function createEngagementRoutes(service: EngagementService) { - const router = new Hono<{ Variables: { user?: AuthUser } }>(); - - router.post('/decks/:slug/star', async (c) => { - const user = requireUser(c.get('user')); - await service.starDeck(user.userId, c.req.param('slug')); - return c.json({ ok: true }); - }); - - router.delete('/decks/:slug/star', async (c) => { - const user = requireUser(c.get('user')); - await service.unstarDeck(user.userId, c.req.param('slug')); - return c.json({ ok: true }); - }); - - router.post('/authors/:slug/follow', async (c) => { - const user = requireUser(c.get('user')); - await service.followAuthor(user.userId, c.req.param('slug')); - return c.json({ ok: true }); - }); - - router.delete('/authors/:slug/follow', async (c) => { - const user = requireUser(c.get('user')); - await service.unfollowAuthor(user.userId, c.req.param('slug')); - return c.json({ ok: true }); - }); - - return router; -} diff --git a/services/cards-server/src/routes/explore.ts b/services/cards-server/src/routes/explore.ts deleted file mode 100644 index f09c87e34..000000000 --- a/services/cards-server/src/routes/explore.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { Hono } from 'hono'; -import type { AuthUser } from '../middleware/jwt-auth'; -import type { ExploreService, SortOption } from '../services/explore'; - -const sorts: SortOption[] = ['recent', 'popular', 'trending']; - -export function createExploreRoutes(service: ExploreService) { - const router = new Hono<{ Variables: { user?: AuthUser } }>(); - - router.get('/explore', async (c) => { - const result = await service.explore(); - return c.json(result); - }); - - router.get('/decks', async (c) => { - const url = new URL(c.req.url); - const sortParam = url.searchParams.get('sort'); - const sort = sorts.includes(sortParam as SortOption) ? (sortParam as SortOption) : 'recent'; - const limit = parseInt(url.searchParams.get('limit') ?? '20', 10); - const offset = parseInt(url.searchParams.get('offset') ?? '0', 10); - - const result = await service.browse({ - q: url.searchParams.get('q') ?? undefined, - tag: url.searchParams.get('tag') ?? undefined, - language: url.searchParams.get('lang') ?? undefined, - authorSlug: url.searchParams.get('author') ?? undefined, - sort, - limit, - offset, - }); - return c.json(result); - }); - - router.get('/tags', async (c) => { - const tree = await service.tagTree(); - return c.json(tree); - }); - - return router; -} diff --git a/services/cards-server/src/routes/health.ts b/services/cards-server/src/routes/health.ts deleted file mode 100644 index 2613bc64c..000000000 --- a/services/cards-server/src/routes/health.ts +++ /dev/null @@ -1,11 +0,0 @@ -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/src/routes/moderation.ts b/services/cards-server/src/routes/moderation.ts deleted file mode 100644 index b5fae5147..000000000 --- a/services/cards-server/src/routes/moderation.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { Hono } from 'hono'; -import { z } from 'zod'; -import type { AuthUser } from '../middleware/jwt-auth'; -import type { ModerationService } from '../services/moderation'; -import { BadRequestError, ForbiddenError, UnauthorizedError } from '../lib/errors'; - -function requireUser(user: AuthUser | undefined): AuthUser { - if (!user || !user.userId) throw new UnauthorizedError(); - return user; -} - -function requireAdmin(user: AuthUser | undefined): AuthUser { - const u = requireUser(user); - if (u.role !== 'admin') throw new ForbiddenError('Admin role required'); - return u; -} - -const reportSchema = z.object({ - deckSlug: z.string().min(1), - cardContentHash: z.string().min(1).optional(), - category: z.enum(['spam', 'copyright', 'nsfw', 'misinformation', 'hate', 'other']), - body: z.string().max(2000).optional(), -}); - -const resolveSchema = z.object({ - action: z.enum(['dismiss', 'takedown', 'ban-author']), - notes: z.string().max(1000).optional(), -}); - -const takedownSchema = z.object({ - reason: z.string().max(1000).optional(), -}); - -const verifySchema = z.object({ - verifiedMana: z.boolean(), -}); - -export function createModerationRoutes(service: ModerationService) { - const router = new Hono<{ Variables: { user?: AuthUser } }>(); - - // User-facing — anyone authed can file a report. - router.post('/reports', async (c) => { - const user = requireUser(c.get('user')); - const parsed = reportSchema.safeParse(await c.req.json().catch(() => ({}))); - if (!parsed.success) throw new BadRequestError('Invalid body', parsed.error.format()); - const row = await service.createReport(user.userId, parsed.data); - return c.json(row, 201); - }); - - // Admin inbox + actions. - router.get('/admin/reports', async (c) => { - requireAdmin(c.get('user')); - const list = await service.listOpen(); - return c.json(list); - }); - - router.post('/admin/reports/:id/resolve', async (c) => { - const admin = requireAdmin(c.get('user')); - const parsed = resolveSchema.safeParse(await c.req.json().catch(() => ({}))); - if (!parsed.success) throw new BadRequestError('Invalid body', parsed.error.format()); - const result = await service.resolveReport(admin.userId, c.req.param('id'), parsed.data); - return c.json(result); - }); - - router.post('/admin/decks/:slug/takedown', async (c) => { - const admin = requireAdmin(c.get('user')); - const parsed = takedownSchema.safeParse(await c.req.json().catch(() => ({}))); - if (!parsed.success) throw new BadRequestError('Invalid body', parsed.error.format()); - const result = await service.takedownDeck( - admin.userId, - c.req.param('slug'), - parsed.data.reason - ); - return c.json(result); - }); - - router.post('/admin/decks/:slug/restore', async (c) => { - const admin = requireAdmin(c.get('user')); - const result = await service.restoreDeck(admin.userId, c.req.param('slug')); - return c.json(result); - }); - - router.post('/admin/authors/:slug/verify', async (c) => { - const admin = requireAdmin(c.get('user')); - const parsed = verifySchema.safeParse(await c.req.json().catch(() => ({}))); - if (!parsed.success) throw new BadRequestError('Invalid body', parsed.error.format()); - const result = await service.setVerifiedMana( - admin.userId, - c.req.param('slug'), - parsed.data.verifiedMana - ); - return c.json(result); - }); - - return router; -} diff --git a/services/cards-server/src/routes/pull-requests.ts b/services/cards-server/src/routes/pull-requests.ts deleted file mode 100644 index 943b0da3d..000000000 --- a/services/cards-server/src/routes/pull-requests.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { Hono } from 'hono'; -import { z } from 'zod'; -import type { AuthUser } from '../middleware/jwt-auth'; -import type { PullRequestService } from '../services/pull-requests'; -import { BadRequestError, UnauthorizedError } from '../lib/errors'; - -function requireUser(user: AuthUser | undefined): AuthUser { - if (!user || !user.userId) throw new UnauthorizedError(); - return user; -} - -const cardTypes = [ - 'basic', - 'basic-reverse', - 'cloze', - 'type-in', - 'image-occlusion', - 'audio', - 'multiple-choice', -] as const; - -const cardPayloadSchema = z.object({ - type: z.enum(cardTypes), - fields: z.record(z.string(), z.string()), -}); - -const createPrSchema = z.object({ - title: z.string().min(1).max(140), - body: z.string().max(4000).optional(), - diff: z.object({ - add: z.array(cardPayloadSchema).default([]), - modify: z - .array( - cardPayloadSchema.extend({ - previousContentHash: z.string().min(1), - }) - ) - .default([]), - remove: z.array(z.object({ contentHash: z.string().min(1) })).default([]), - }), -}); - -const mergeSchema = z.object({ - newSemver: z - .string() - .regex(/^\d+\.\d+\.\d+$/) - .optional(), - mergeNote: z.string().max(2000).optional(), -}); - -export function createPullRequestRoutes(service: PullRequestService) { - const router = new Hono<{ Variables: { user?: AuthUser } }>(); - - router.post('/decks/:slug/pull-requests', async (c) => { - const user = requireUser(c.get('user')); - const parsed = createPrSchema.safeParse(await c.req.json().catch(() => ({}))); - if (!parsed.success) throw new BadRequestError('Invalid body', parsed.error.format()); - const pr = await service.create(user.userId, c.req.param('slug'), parsed.data); - return c.json(pr, 201); - }); - - router.get('/decks/:slug/pull-requests', async (c) => { - const url = new URL(c.req.url); - const status = url.searchParams.get('status'); - const valid = ['open', 'merged', 'closed', 'rejected'] as const; - const statusFilter = (valid as readonly string[]).includes(status ?? '') - ? (status as (typeof valid)[number]) - : undefined; - const list = await service.list(c.req.param('slug'), statusFilter); - return c.json(list); - }); - - router.get('/pull-requests/:id', async (c) => { - const pr = await service.get(c.req.param('id')); - return c.json(pr); - }); - - router.post('/pull-requests/:id/close', async (c) => { - const user = requireUser(c.get('user')); - await service.close(user.userId, c.req.param('id')); - return c.json({ ok: true }); - }); - - router.post('/pull-requests/:id/reject', async (c) => { - const user = requireUser(c.get('user')); - await service.reject(user.userId, c.req.param('id')); - return c.json({ ok: true }); - }); - - router.post('/pull-requests/:id/merge', async (c) => { - const user = requireUser(c.get('user')); - const parsed = mergeSchema.safeParse(await c.req.json().catch(() => ({}))); - if (!parsed.success) throw new BadRequestError('Invalid body', parsed.error.format()); - const result = await service.merge(user.userId, c.req.param('id'), parsed.data); - return c.json(result, 201); - }); - - return router; -} diff --git a/services/cards-server/src/routes/purchases.ts b/services/cards-server/src/routes/purchases.ts deleted file mode 100644 index 56e9a34cf..000000000 --- a/services/cards-server/src/routes/purchases.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { Hono } from 'hono'; -import type { AuthUser } from '../middleware/jwt-auth'; -import type { PurchaseService } from '../services/purchases'; -import { UnauthorizedError } from '../lib/errors'; - -function requireUser(user: AuthUser | undefined): AuthUser { - if (!user || !user.userId) throw new UnauthorizedError(); - return user; -} - -export function createPurchaseRoutes(service: PurchaseService) { - const router = new Hono<{ Variables: { user?: AuthUser } }>(); - - router.post('/decks/:slug/purchase', async (c) => { - const user = requireUser(c.get('user')); - const result = await service.purchase(user.userId, c.req.param('slug')); - return c.json(result, result.alreadyOwned ? 200 : 201); - }); - - router.get('/me/purchases', async (c) => { - const user = requireUser(c.get('user')); - const list = await service.listForBuyer(user.userId); - return c.json(list); - }); - - router.get('/authors/me/payouts', async (c) => { - const user = requireUser(c.get('user')); - const list = await service.listPayoutsForAuthor(user.userId); - return c.json(list); - }); - - return router; -} diff --git a/services/cards-server/src/routes/subscriptions.ts b/services/cards-server/src/routes/subscriptions.ts deleted file mode 100644 index a80257fa1..000000000 --- a/services/cards-server/src/routes/subscriptions.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { Hono } from 'hono'; -import type { AuthUser } from '../middleware/jwt-auth'; -import type { SubscriptionService } from '../services/subscriptions'; -import { BadRequestError, UnauthorizedError } from '../lib/errors'; - -function requireUser(user: AuthUser | undefined): AuthUser { - if (!user || !user.userId) throw new UnauthorizedError(); - return user; -} - -export function createSubscriptionRoutes(service: SubscriptionService) { - const router = new Hono<{ Variables: { user?: AuthUser } }>(); - - // User-scoped routes ----------------------------------------------------- - - router.get('/me/subscriptions', async (c) => { - const user = requireUser(c.get('user')); - const list = await service.listForUser(user.userId); - return c.json(list); - }); - - router.post('/decks/:slug/subscribe', async (c) => { - const user = requireUser(c.get('user')); - const result = await service.subscribe(user.userId, c.req.param('slug')); - return c.json(result, 201); - }); - - router.delete('/decks/:slug/subscribe', async (c) => { - const user = requireUser(c.get('user')); - await service.unsubscribe(user.userId, c.req.param('slug')); - return c.json({ ok: true }); - }); - - // Public read routes ----------------------------------------------------- - - router.get('/decks/:slug/versions/:semver', async (c) => { - const semver = c.req.param('semver'); - if (!/^\d+\.\d+\.\d+$/.test(semver)) { - throw new BadRequestError('semver must look like 1.0.0'); - } - const payload = await service.versionWithCards(c.req.param('slug'), semver); - return c.json(payload); - }); - - router.get('/decks/:slug/diff', async (c) => { - const url = new URL(c.req.url); - const from = url.searchParams.get('from'); - if (!from || !/^\d+\.\d+\.\d+$/.test(from)) { - throw new BadRequestError('?from= required, e.g. ?from=1.0.0'); - } - const diff = await service.diffSince(c.req.param('slug'), from); - return c.json(diff); - }); - - return router; -} diff --git a/services/cards-server/src/services/authors.ts b/services/cards-server/src/services/authors.ts deleted file mode 100644 index bdc3195e3..000000000 --- a/services/cards-server/src/services/authors.ts +++ /dev/null @@ -1,104 +0,0 @@ -/** - * Author service — CRUD on `cards.authors` plus the lookups the - * routes need (by-slug, by-userId). - * - * Slug is unique per author. We don't auto-suggest slugs server-side; - * the client picks one and we validate. If a user changes their slug, - * the old slug isn't preserved (no redirects yet — Phase η maybe). - */ - -import { eq } from 'drizzle-orm'; -import type { Database } from '../db/connection'; -import { authors } from '../db/schema'; -import { validateSlug } from '../lib/slug'; -import { BadRequestError, ConflictError, NotFoundError } from '../lib/errors'; - -export interface AuthorInput { - slug: string; - displayName: string; - bio?: string; - avatarUrl?: string; - pseudonym?: boolean; -} - -export class AuthorService { - constructor(private readonly db: Database) {} - - async upsertMe(userId: string, input: AuthorInput) { - const validation = validateSlug(input.slug); - if (!validation.ok) { - throw new BadRequestError(`Slug invalid: ${validation.reason}`); - } - - // Slug must be free or already owned by us. - const existingBySlug = await this.db.query.authors.findFirst({ - where: eq(authors.slug, input.slug), - }); - if (existingBySlug && existingBySlug.userId !== userId) { - throw new ConflictError('Slug already taken'); - } - - const existing = await this.db.query.authors.findFirst({ - where: eq(authors.userId, userId), - }); - - if (existing) { - const [updated] = await this.db - .update(authors) - .set({ - slug: input.slug, - displayName: input.displayName, - bio: input.bio, - avatarUrl: input.avatarUrl, - pseudonym: input.pseudonym ?? existing.pseudonym, - }) - .where(eq(authors.userId, userId)) - .returning(); - return updated; - } - - const [created] = await this.db - .insert(authors) - .values({ - userId, - slug: input.slug, - displayName: input.displayName, - bio: input.bio, - avatarUrl: input.avatarUrl, - pseudonym: input.pseudonym ?? false, - }) - .returning(); - return created; - } - - async getByUserId(userId: string) { - const row = await this.db.query.authors.findFirst({ where: eq(authors.userId, userId) }); - return row ?? null; - } - - /** Public profile lookup — strips bannedReason etc. */ - async getPublicBySlug(slug: string) { - const row = await this.db.query.authors.findFirst({ where: eq(authors.slug, slug) }); - if (!row) throw new NotFoundError('Author not found'); - return { - slug: row.slug, - displayName: row.displayName, - bio: row.bio, - avatarUrl: row.avatarUrl, - joinedAt: row.joinedAt, - pseudonym: row.pseudonym, - verifiedMana: row.verifiedMana, - verifiedCommunity: row.verifiedCommunity, - banned: row.bannedAt !== null, - }; - } - - async assertNotBanned(userId: string) { - const row = await this.getByUserId(userId); - if (!row) throw new BadRequestError('You need an author profile first (POST /v1/authors/me).'); - if (row.bannedAt) { - throw new BadRequestError(`Author banned: ${row.bannedReason ?? 'no reason given'}`); - } - return row; - } -} diff --git a/services/cards-server/src/services/decks.ts b/services/cards-server/src/services/decks.ts deleted file mode 100644 index 6d5d53da1..000000000 --- a/services/cards-server/src/services/decks.ts +++ /dev/null @@ -1,223 +0,0 @@ -/** - * Deck service — init + publish. - * - * `init` claims a slug and creates a `cards.decks` row with no - * version yet (so authors can fiddle with metadata before their first - * publish). `publish` runs the AI-mod first-pass, computes per-card - * + per-version content hashes, writes a new immutable version + its - * cards, and atomically updates `latest_version_id` on the deck. - * - * Per MARKETPLACE_PLAN: a `block` verdict from AI mod refuses the - * publish outright. A `flag` verdict still publishes (so the deck - * isn't blocked on slow human review) but writes a row into - * `ai_moderation_log` so the moderation inbox surfaces it. - */ - -import { and, eq, sql } from 'drizzle-orm'; -import type { Database } from '../db/connection'; -import { publicDecks, publicDeckVersions, publicDeckCards, aiModerationLog } from '../db/schema'; -import { validateSlug } from '../lib/slug'; -import { hashCard, hashVersionCards } from '../lib/hash'; -import { moderateDeckContent } from '../lib/ai-moderation'; -import { BadRequestError, ConflictError, ForbiddenError, NotFoundError } from '../lib/errors'; - -export interface InitDeckInput { - slug: string; - title: string; - description?: string; - language?: string; - license?: string; - priceCredits?: number; -} - -export interface PublishInput { - semver: string; - changelog?: string; - cards: { - type: - | 'basic' - | 'basic-reverse' - | 'cloze' - | 'type-in' - | 'image-occlusion' - | 'audio' - | 'multiple-choice'; - fields: Record; - }[]; -} - -export interface PublishResult { - deck: typeof publicDecks.$inferSelect; - version: typeof publicDeckVersions.$inferSelect; - moderation: { verdict: 'pass' | 'flag' | 'block'; categories: string[] }; -} - -const SEMVER_RE = /^(\d+)\.(\d+)\.(\d+)$/; - -function validatePrice(price: number, license: string) { - if (price < 0) throw new BadRequestError('priceCredits cannot be negative'); - if (price > 0 && license !== 'Cardecky-Pro-Only-1.0') { - throw new BadRequestError('Paid decks must use the Cardecky-Pro-Only-1.0 license'); - } -} - -export class DeckService { - constructor( - private readonly db: Database, - private readonly llmUrl: string - ) {} - - async init(ownerUserId: string, input: InitDeckInput) { - const validation = validateSlug(input.slug); - if (!validation.ok) throw new BadRequestError(`Slug invalid: ${validation.reason}`); - - const license = input.license ?? 'Cardecky-Personal-Use-1.0'; - const priceCredits = input.priceCredits ?? 0; - validatePrice(priceCredits, license); - - const existing = await this.db.query.publicDecks.findFirst({ - where: eq(publicDecks.slug, input.slug), - }); - if (existing) throw new ConflictError('Slug already taken'); - - const [created] = await this.db - .insert(publicDecks) - .values({ - slug: input.slug, - title: input.title, - description: input.description, - language: input.language, - license, - priceCredits, - ownerUserId, - }) - .returning(); - return created; - } - - async getBySlug(slug: string) { - const deck = await this.db.query.publicDecks.findFirst({ - where: eq(publicDecks.slug, slug), - }); - if (!deck) throw new NotFoundError('Deck not found'); - - const version = deck.latestVersionId - ? await this.db.query.publicDeckVersions.findFirst({ - where: eq(publicDeckVersions.id, deck.latestVersionId), - }) - : null; - - return { deck, latestVersion: version }; - } - - async publish(ownerUserId: string, slug: string, input: PublishInput): Promise { - if (!SEMVER_RE.test(input.semver)) { - throw new BadRequestError('semver must look like 1.0.0'); - } - if (input.cards.length === 0) { - throw new BadRequestError('A version needs at least one card'); - } - - const deck = await this.db.query.publicDecks.findFirst({ - where: eq(publicDecks.slug, slug), - }); - if (!deck) throw new NotFoundError('Deck not found'); - if (deck.ownerUserId !== ownerUserId) { - throw new ForbiddenError('Only the deck owner can publish'); - } - if (deck.isTakedown) throw new ForbiddenError('Deck is under takedown'); - - // semver must be strictly greater than the latest published - // version so version history stays linear. - if (deck.latestVersionId) { - const latest = await this.db.query.publicDeckVersions.findFirst({ - where: eq(publicDeckVersions.id, deck.latestVersionId), - }); - if (latest && !semverGreater(input.semver, latest.semver)) { - throw new ConflictError(`semver ${input.semver} must be > ${latest.semver}`); - } - } - - // 1) AI moderation first-pass. - const moderation = await moderateDeckContent( - { - title: deck.title, - description: deck.description ?? undefined, - cards: input.cards.map((c) => ({ fields: c.fields })), - }, - this.llmUrl - ); - if (moderation.verdict === 'block') { - throw new ForbiddenError( - `Refused by content moderation: ${moderation.rationale || 'no rationale'}` - ); - } - - // 2) Compute hashes. - const cardsWithOrd = input.cards.map((c, i) => ({ ...c, ord: i })); - const versionContentHash = hashVersionCards(cardsWithOrd); - - // 3) Insert version + cards + flip latest_version_id atomically. - const result = await this.db.transaction(async (tx) => { - const [version] = await tx - .insert(publicDeckVersions) - .values({ - deckId: deck.id, - semver: input.semver, - changelog: input.changelog, - contentHash: versionContentHash, - cardCount: cardsWithOrd.length, - }) - .returning(); - - await tx.insert(publicDeckCards).values( - cardsWithOrd.map((c) => ({ - versionId: version.id, - type: c.type, - fields: c.fields, - ord: c.ord, - contentHash: hashCard(c), - })) - ); - - await tx.insert(aiModerationLog).values({ - versionId: version.id, - verdict: moderation.verdict, - categories: moderation.categories, - model: moderation.model, - rationale: moderation.rationale, - }); - - const [updatedDeck] = await tx - .update(publicDecks) - .set({ latestVersionId: version.id }) - .where(and(eq(publicDecks.id, deck.id))) - .returning(); - - return { deck: updatedDeck, version }; - }); - - return { - deck: result.deck, - version: result.version, - moderation: { verdict: moderation.verdict, categories: moderation.categories }, - }; - } -} - -function semverGreater(a: string, b: string): boolean { - const matchA = a.match(SEMVER_RE); - const matchB = b.match(SEMVER_RE); - if (!matchA || !matchB) return false; - for (let i = 1; i <= 3; i++) { - const da = Number.parseInt(matchA[i], 10); - const db = Number.parseInt(matchB[i], 10); - if (da > db) return true; - if (da < db) return false; - } - return false; -} - -// Silence unused-binding lint for `sql` import — we keep it ready for -// upcoming routes (server-side orderings / counts). -void sql; diff --git a/services/cards-server/src/services/discussions.ts b/services/cards-server/src/services/discussions.ts deleted file mode 100644 index f7521b780..000000000 --- a/services/cards-server/src/services/discussions.ts +++ /dev/null @@ -1,109 +0,0 @@ -/** - * Card discussions — lightweight inline threads keyed by - * `card_content_hash` (not card-id) so a thread survives across - * version bumps as long as the card content stays. - * - * Threads are flat-with-parent: every reply has `parent_id` → - * something else in the same `card_content_hash` group. The UI - * renders a one-level-deep tree (Reddit-style with a max depth) — - * if we want full nesting later it's already there. - */ - -import { and, asc, eq, sql } from 'drizzle-orm'; -import type { Database } from '../db/connection'; -import { cardDiscussions, publicDecks } from '../db/schema'; -import { ForbiddenError, NotFoundError } from '../lib/errors'; - -export class DiscussionService { - constructor(private readonly db: Database) {} - - async post( - userId: string, - deckSlug: string, - cardContentHash: string, - body: string, - parentId?: string - ) { - const deck = await this.db.query.publicDecks.findFirst({ - where: eq(publicDecks.slug, deckSlug), - }); - if (!deck) throw new NotFoundError('Deck not found'); - - if (parentId) { - const parent = await this.db.query.cardDiscussions.findFirst({ - where: eq(cardDiscussions.id, parentId), - }); - if (!parent) throw new NotFoundError('Parent comment not found'); - if (parent.cardContentHash !== cardContentHash) { - throw new ForbiddenError('Parent comment is on a different card'); - } - } - - const [row] = await this.db - .insert(cardDiscussions) - .values({ - cardContentHash, - deckId: deck.id, - authorUserId: userId, - parentId: parentId ?? null, - body, - }) - .returning(); - return row; - } - - /** - * Bulk count of (visible) comments per card-content-hash for one - * deck — powers the "Karten" overview on the public deck page so - * we don't fan out one request per card. - */ - async countsForDeck(deckSlug: string): Promise> { - const deck = await this.db.query.publicDecks.findFirst({ - where: eq(publicDecks.slug, deckSlug), - }); - if (!deck) throw new NotFoundError('Deck not found'); - - const rows = await this.db - .select({ - contentHash: cardDiscussions.cardContentHash, - count: sql`count(*)::int`.as('count'), - }) - .from(cardDiscussions) - .where(and(eq(cardDiscussions.deckId, deck.id), eq(cardDiscussions.hidden, false))) - .groupBy(cardDiscussions.cardContentHash); - - const out: Record = {}; - for (const r of rows) out[r.contentHash] = r.count; - return out; - } - - async listForCard(cardContentHash: string) { - const rows = await this.db - .select() - .from(cardDiscussions) - .where( - and(eq(cardDiscussions.cardContentHash, cardContentHash), eq(cardDiscussions.hidden, false)) - ) - .orderBy(asc(cardDiscussions.createdAt)); - return rows; - } - - async hide(actorUserId: string, discussionId: string) { - const row = await this.db.query.cardDiscussions.findFirst({ - where: eq(cardDiscussions.id, discussionId), - }); - if (!row) throw new NotFoundError('Discussion not found'); - const deck = await this.db.query.publicDecks.findFirst({ - where: eq(publicDecks.id, row.deckId), - }); - if (!deck) throw new NotFoundError('Deck not found'); - // Author of the comment OR deck owner can hide. - if (row.authorUserId !== actorUserId && deck.ownerUserId !== actorUserId) { - throw new ForbiddenError('Not allowed to hide this comment'); - } - await this.db - .update(cardDiscussions) - .set({ hidden: true }) - .where(eq(cardDiscussions.id, discussionId)); - } -} diff --git a/services/cards-server/src/services/engagement.ts b/services/cards-server/src/services/engagement.ts deleted file mode 100644 index 749547925..000000000 --- a/services/cards-server/src/services/engagement.ts +++ /dev/null @@ -1,79 +0,0 @@ -/** - * Star + Follow primitives. Both are idempotent and safe to retry. - */ - -import { and, eq } from 'drizzle-orm'; -import type { Database } from '../db/connection'; -import { authorFollows, authors, deckStars, publicDecks } from '../db/schema'; -import { ConflictError, NotFoundError } from '../lib/errors'; - -export class EngagementService { - constructor(private readonly db: Database) {} - - async starDeck(userId: string, deckSlug: string) { - const deck = await this.db.query.publicDecks.findFirst({ - where: eq(publicDecks.slug, deckSlug), - }); - if (!deck) throw new NotFoundError('Deck not found'); - await this.db.insert(deckStars).values({ userId, deckId: deck.id }).onConflictDoNothing(); - } - - async unstarDeck(userId: string, deckSlug: string) { - const deck = await this.db.query.publicDecks.findFirst({ - where: eq(publicDecks.slug, deckSlug), - }); - if (!deck) throw new NotFoundError('Deck not found'); - await this.db - .delete(deckStars) - .where(and(eq(deckStars.userId, userId), eq(deckStars.deckId, deck.id))); - } - - async isDeckStarred(userId: string, deckSlug: string): Promise { - const row = await this.db - .select({ id: deckStars.deckId }) - .from(deckStars) - .innerJoin(publicDecks, eq(publicDecks.id, deckStars.deckId)) - .where(and(eq(deckStars.userId, userId), eq(publicDecks.slug, deckSlug))) - .limit(1); - return row.length > 0; - } - - async followAuthor(followerUserId: string, authorSlug: string) { - const author = await this.db.query.authors.findFirst({ - where: eq(authors.slug, authorSlug), - }); - if (!author) throw new NotFoundError('Author not found'); - if (author.userId === followerUserId) { - throw new ConflictError('You cannot follow yourself'); - } - await this.db - .insert(authorFollows) - .values({ followerUserId, authorUserId: author.userId }) - .onConflictDoNothing(); - } - - async unfollowAuthor(followerUserId: string, authorSlug: string) { - const author = await this.db.query.authors.findFirst({ - where: eq(authors.slug, authorSlug), - }); - if (!author) throw new NotFoundError('Author not found'); - await this.db - .delete(authorFollows) - .where( - and( - eq(authorFollows.followerUserId, followerUserId), - eq(authorFollows.authorUserId, author.userId) - ) - ); - } - - async isFollowing(followerUserId: string, authorSlug: string): Promise { - const row = await this.db - .select({ id: authorFollows.authorUserId }) - .from(authorFollows) - .innerJoin(authors, eq(authors.userId, authorFollows.authorUserId)) - .where(and(eq(authorFollows.followerUserId, followerUserId), eq(authors.slug, authorSlug))) - .limit(1); - return row.length > 0; - } -} diff --git a/services/cards-server/src/services/explore.ts b/services/cards-server/src/services/explore.ts deleted file mode 100644 index f1c846c9d..000000000 --- a/services/cards-server/src/services/explore.ts +++ /dev/null @@ -1,195 +0,0 @@ -/** - * Discovery service — browse, search, featured, trending, per-author - * deck lists, tag hierarchy. Pure read-only. - * - * Search uses Postgres `to_tsvector` over (title, description) so we - * don't depend on a separate index for Phase γ; Meilisearch lands in - * Phase ι if/when this becomes the bottleneck. Trending = simple - * recent-stars-velocity over the last 7 days; gamed at small N, fine - * once volume picks up — replaceable without API changes. - */ - -import { and, desc, eq, gte, ilike, isNull, or, sql, count } from 'drizzle-orm'; -import type { Database } from '../db/connection'; -import { - authors, - deckStars, - deckSubscriptions, - deckTags, - publicDecks, - publicDeckVersions, - tagDefinitions, -} from '../db/schema'; - -export interface DeckListEntry { - slug: string; - title: string; - description: string | null; - language: string | null; - license: string; - priceCredits: number; - cardCount: number; - starCount: number; - subscriberCount: number; - isFeatured: boolean; - createdAt: Date; - owner: { slug: string; displayName: string; verifiedMana: boolean; verifiedCommunity: boolean }; -} - -const SORT_OPTIONS = ['recent', 'popular', 'trending'] as const; -export type SortOption = (typeof SORT_OPTIONS)[number]; - -export interface BrowseFilter { - q?: string; - tag?: string; - language?: string; - authorSlug?: string; - sort?: SortOption; - limit?: number; - offset?: number; -} - -export class ExploreService { - constructor(private readonly db: Database) {} - - async browse(filter: BrowseFilter): Promise<{ items: DeckListEntry[]; total: number }> { - const limit = Math.min(filter.limit ?? 20, 100); - const offset = filter.offset ?? 0; - const sort = filter.sort ?? 'recent'; - - // Base join: deck × owner-author × latest-version. We hit - // Drizzle's relational query API for predictable joins instead - // of building a giant select-with-joins by hand. - const conditions = [eq(publicDecks.isTakedown, false)]; - if (filter.language) conditions.push(eq(publicDecks.language, filter.language)); - if (filter.q) { - conditions.push( - or( - ilike(publicDecks.title, `%${filter.q}%`), - ilike(publicDecks.description, `%${filter.q}%`) - )! - ); - } - if (filter.authorSlug) { - conditions.push( - eq( - publicDecks.ownerUserId, - sql`(SELECT user_id FROM cards.authors WHERE slug = ${filter.authorSlug} LIMIT 1)` - ) - ); - } - if (filter.tag) { - conditions.push( - sql`EXISTS (SELECT 1 FROM cards.deck_tags dt JOIN cards.tag_definitions td ON td.id = dt.tag_id WHERE dt.deck_id = ${publicDecks.id} AND td.slug = ${filter.tag})` - ); - } - - // Pre-compute counts via subqueries; avoids N+1. - const starCount = sql`(SELECT count(*)::int FROM cards.deck_stars s WHERE s.deck_id = ${publicDecks.id})`; - const subscriberCount = sql`(SELECT count(*)::int FROM cards.deck_subscriptions s WHERE s.deck_id = ${publicDecks.id})`; - const cardCountExpr = sql`COALESCE((SELECT v.card_count FROM cards.deck_versions v WHERE v.id = ${publicDecks.latestVersionId}), 0)`; - - const sortClause = - sort === 'popular' - ? desc(starCount) - : sort === 'trending' - ? desc( - sql`(SELECT count(*)::int FROM cards.deck_stars s WHERE s.deck_id = ${publicDecks.id} AND s.starred_at >= now() - interval '7 days')` - ) - : desc(publicDecks.createdAt); - - const baseQuery = this.db - .select({ - slug: publicDecks.slug, - title: publicDecks.title, - description: publicDecks.description, - language: publicDecks.language, - license: publicDecks.license, - priceCredits: publicDecks.priceCredits, - cardCount: cardCountExpr, - starCount, - subscriberCount, - isFeatured: publicDecks.isFeatured, - createdAt: publicDecks.createdAt, - ownerSlug: authors.slug, - ownerDisplayName: authors.displayName, - ownerVerifiedMana: authors.verifiedMana, - ownerVerifiedCommunity: authors.verifiedCommunity, - }) - .from(publicDecks) - .innerJoin(authors, eq(authors.userId, publicDecks.ownerUserId)) - .where(and(...conditions)) - .orderBy(sortClause) - .limit(limit) - .offset(offset); - - const totalQuery = this.db - .select({ value: count() }) - .from(publicDecks) - .innerJoin(authors, eq(authors.userId, publicDecks.ownerUserId)) - .where(and(...conditions)); - - const [rows, totalResult] = await Promise.all([baseQuery, totalQuery]); - - return { - items: rows.map((r) => ({ - slug: r.slug, - title: r.title, - description: r.description, - language: r.language, - license: r.license, - priceCredits: r.priceCredits, - cardCount: Number(r.cardCount), - starCount: Number(r.starCount), - subscriberCount: Number(r.subscriberCount), - isFeatured: r.isFeatured, - createdAt: r.createdAt, - owner: { - slug: r.ownerSlug, - displayName: r.ownerDisplayName, - verifiedMana: r.ownerVerifiedMana, - verifiedCommunity: r.ownerVerifiedCommunity, - }, - })), - total: totalResult[0]?.value ?? 0, - }; - } - - /** Featured + Trending side-by-side for the /explore landing. */ - async explore(): Promise<{ featured: DeckListEntry[]; trending: DeckListEntry[] }> { - const [featuredResult, trendingResult] = await Promise.all([ - this.browse({ sort: 'popular', limit: 8 }).then((r) => - r.items.filter((d) => d.isFeatured).slice(0, 8) - ), - this.browse({ sort: 'trending', limit: 8 }), - ]); - return { featured: featuredResult, trending: trendingResult.items }; - } - - async tagTree() { - const rows = await this.db - .select() - .from(tagDefinitions) - .orderBy(tagDefinitions.parentId, tagDefinitions.name); - return rows; - } - - async curatedTagsOnly() { - return this.db - .select() - .from(tagDefinitions) - .where(eq(tagDefinitions.curated, true)) - .orderBy(tagDefinitions.name); - } - - // Silence unused-binding lint for imports that downstream queries - // will pull in. - _keepAlive() { - void deckSubscriptions; - void deckStars; - void deckTags; - void publicDeckVersions; - void isNull; - void gte; - } -} diff --git a/services/cards-server/src/services/moderation.ts b/services/cards-server/src/services/moderation.ts deleted file mode 100644 index 0cb6a096a..000000000 --- a/services/cards-server/src/services/moderation.ts +++ /dev/null @@ -1,280 +0,0 @@ -/** - * Phase η.1 — User-submitted reports + admin actions. - * - * Anyone authed can file a report against a deck (optionally scoped - * to one card via `cardContentHash`). Admins (`role === 'admin'`) - * pull the open inbox, dismiss false positives, take a deck down, or - * ban an author. The inbox auto-resolves all open reports for a deck - * when a takedown lands so admins don't have to chase duplicates. - */ - -import { and, desc, eq, isNull } from 'drizzle-orm'; -import type { Database } from '../db/connection'; -import { - authors, - deckPullRequests, - deckReports, - publicDecks, - type reportCategoryEnum, -} from '../db/schema'; -import { BadRequestError, ForbiddenError, NotFoundError } from '../lib/errors'; -import type { NotifyClient } from '../lib/notify'; - -type ReportCategory = (typeof reportCategoryEnum.enumValues)[number]; - -export interface CreateReportInput { - deckSlug: string; - cardContentHash?: string; - category: ReportCategory; - body?: string; -} - -export interface ResolveReportInput { - action: 'dismiss' | 'takedown' | 'ban-author'; - notes?: string; -} - -const VALID_CATEGORIES = new Set([ - 'spam', - 'copyright', - 'nsfw', - 'misinformation', - 'hate', - 'other', -]); - -export class ModerationService { - constructor( - private readonly db: Database, - private readonly notify?: NotifyClient - ) {} - - async createReport(reporterUserId: string, input: CreateReportInput) { - if (!VALID_CATEGORIES.has(input.category)) { - throw new BadRequestError(`Unknown report category: ${input.category}`); - } - const deck = await this.db.query.publicDecks.findFirst({ - where: eq(publicDecks.slug, input.deckSlug), - }); - if (!deck) throw new NotFoundError('Deck not found'); - - const [row] = await this.db - .insert(deckReports) - .values({ - deckId: deck.id, - versionId: deck.latestVersionId ?? null, - cardContentHash: input.cardContentHash ?? null, - reporterUserId, - category: input.category, - body: input.body ?? null, - }) - .returning(); - return row; - } - - async listOpen(limit = 50) { - return this.db - .select({ - id: deckReports.id, - deckId: deckReports.deckId, - deckSlug: publicDecks.slug, - deckTitle: publicDecks.title, - cardContentHash: deckReports.cardContentHash, - reporterUserId: deckReports.reporterUserId, - category: deckReports.category, - body: deckReports.body, - status: deckReports.status, - createdAt: deckReports.createdAt, - }) - .from(deckReports) - .innerJoin(publicDecks, eq(deckReports.deckId, publicDecks.id)) - .where(eq(deckReports.status, 'open')) - .orderBy(desc(deckReports.createdAt)) - .limit(limit); - } - - async resolveReport(adminUserId: string, reportId: string, input: ResolveReportInput) { - const report = await this.db.query.deckReports.findFirst({ - where: eq(deckReports.id, reportId), - }); - if (!report) throw new NotFoundError('Report not found'); - if (report.status !== 'open') { - throw new BadRequestError(`Report already ${report.status}`); - } - const deck = await this.db.query.publicDecks.findFirst({ - where: eq(publicDecks.id, report.deckId), - }); - if (!deck) throw new NotFoundError('Deck disappeared'); - - if (input.action === 'dismiss') { - await this.markResolved(reportId, adminUserId, 'dismissed', input.notes); - return { action: 'dismissed' as const }; - } - - if (input.action === 'takedown') { - await this.takedownDeck(adminUserId, deck.slug, input.notes); - await this.markResolved(reportId, adminUserId, 'actioned', input.notes); - return { action: 'takedown' as const }; - } - - if (input.action === 'ban-author') { - await this.banAuthor(adminUserId, deck.ownerUserId, input.notes); - // A banned author's decks get taken down too — saves a click. - await this.takedownDeck(adminUserId, deck.slug, input.notes ?? 'Author banned'); - await this.markResolved(reportId, adminUserId, 'actioned', input.notes); - return { action: 'ban-author' as const }; - } - - throw new BadRequestError(`Unknown action: ${input.action as string}`); - } - - private async markResolved( - reportId: string, - adminUserId: string, - status: 'dismissed' | 'actioned', - notes: string | undefined - ) { - await this.db - .update(deckReports) - .set({ - status, - resolvedBy: adminUserId, - resolvedAt: new Date(), - resolutionNotes: notes ?? null, - }) - .where(eq(deckReports.id, reportId)); - } - - async takedownDeck(adminUserId: string, deckSlug: string, reason?: string) { - const deck = await this.db.query.publicDecks.findFirst({ - where: eq(publicDecks.slug, deckSlug), - }); - if (!deck) throw new NotFoundError('Deck not found'); - if (deck.isTakedown) return { alreadyDown: true }; - - await this.db.transaction(async (tx) => { - await tx - .update(publicDecks) - .set({ - isTakedown: true, - takedownAt: new Date(), - takedownReason: reason ?? 'Moderation action', - }) - .where(eq(publicDecks.id, deck.id)); - - // Auto-close any other open reports against the same deck. - await tx - .update(deckReports) - .set({ - status: 'actioned', - resolvedBy: adminUserId, - resolvedAt: new Date(), - resolutionNotes: 'Auto-closed by takedown', - }) - .where(and(eq(deckReports.deckId, deck.id), eq(deckReports.status, 'open'))); - - // Open PRs against the deck are no longer mergeable; mark them - // closed so authors / contributors see clear state. - await tx - .update(deckPullRequests) - .set({ - status: 'closed', - resolvedAt: new Date(), - }) - .where(and(eq(deckPullRequests.deckId, deck.id), eq(deckPullRequests.status, 'open'))); - }); - - if (this.notify) { - void this.notify.send({ - channel: 'email', - userId: deck.ownerUserId, - subject: `Dein Deck „${deck.title}" wurde entfernt`, - body: `Dein Deck „${deck.title}" wurde von der Moderation entfernt.${ - reason ? `\n\nGrund: ${reason}` : '' - }\n\nDu hast 30 Tage Zeit, gegen die Entscheidung Einspruch einzulegen.`, - data: { - type: 'cards.deck.takedown', - deckSlug: deck.slug, - reason: reason ?? null, - }, - externalId: `cards.deck.takedown.${deck.id}`, - }); - } - - return { alreadyDown: false }; - } - - async banAuthor(adminUserId: string, targetUserId: string, reason?: string) { - const author = await this.db.query.authors.findFirst({ - where: eq(authors.userId, targetUserId), - }); - if (!author) throw new NotFoundError('Author not found'); - if (author.bannedAt) return { alreadyBanned: true }; - - await this.db - .update(authors) - .set({ bannedAt: new Date() }) - .where(eq(authors.userId, targetUserId)); - - // Take down every deck owned by the banned author. - const banned = await this.db - .select({ slug: publicDecks.slug }) - .from(publicDecks) - .where(and(eq(publicDecks.ownerUserId, targetUserId), eq(publicDecks.isTakedown, false))); - for (const d of banned) { - await this.takedownDeck(adminUserId, d.slug, reason ?? 'Author banned'); - } - - return { alreadyBanned: false }; - } - - async setVerifiedMana(adminUserId: string, authorSlug: string, verified: boolean) { - void adminUserId; - const author = await this.db.query.authors.findFirst({ - where: eq(authors.slug, authorSlug), - }); - if (!author) throw new NotFoundError('Author not found'); - await this.db - .update(authors) - .set({ verifiedMana: verified }) - .where(eq(authors.userId, author.userId)); - - if (this.notify) { - void this.notify.send({ - channel: 'email', - userId: author.userId, - subject: verified ? '🛡️ Du bist jetzt Mana-Verifiziert' : 'Mana-Verifizierung entzogen', - body: verified - ? 'Mana-e.V. hat dich als verifizierten Author bestätigt. Dein Author-Cut steigt von 80% auf 90%.' - : 'Deine Mana-Verifizierung wurde entzogen. Bei Fragen: kontakt@mana.how.', - data: { - type: 'cards.author.verified', - authorSlug, - verified, - }, - externalId: `cards.author.verified.${author.userId}.${verified ? '1' : '0'}.${Date.now()}`, - }); - } - - return { authorSlug, verifiedMana: verified }; - } - - /** - * Lift a takedown — used during appeals. Reports stay closed. - */ - async restoreDeck(adminUserId: string, deckSlug: string) { - void adminUserId; - const deck = await this.db.query.publicDecks.findFirst({ - where: eq(publicDecks.slug, deckSlug), - }); - if (!deck) throw new NotFoundError('Deck not found'); - if (!deck.isTakedown) throw new BadRequestError('Deck is not under takedown'); - - await this.db - .update(publicDecks) - .set({ isTakedown: false, takedownAt: null, takedownReason: null }) - .where(eq(publicDecks.id, deck.id)); - void isNull; - return { restored: true }; - } -} diff --git a/services/cards-server/src/services/pull-requests.ts b/services/cards-server/src/services/pull-requests.ts deleted file mode 100644 index 97f679cd3..000000000 --- a/services/cards-server/src/services/pull-requests.ts +++ /dev/null @@ -1,318 +0,0 @@ -/** - * Pull-requests on decks. The differentiator vs. Anki/Quizlet/etc.: - * subscribers can submit a card-level patch, the deck author reviews - * + merges, and the merge auto-creates a new version that ripples - * through every other subscriber's smart-merge. - * - * The diff payload mirrors GitHub's three-way model in the small: - * - add: cards to insert (server picks the next ord) - * - modify: replace existing cards by previous-content-hash - * - remove: drop cards by content-hash - * - * Status lifecycle: - * open ──merge──► merged (creates a new deck_version) - * open ──close──► closed (author OR PR-author can close) - * open ──reject─► rejected (author-only — distinct from "closed" - * so the PR-author sees clear feedback) - * - * Merging bumps the deck's semver minor by default (1.2.0 → 1.3.0) - * unless the request specifies otherwise. Author can override at - * merge-time. - */ - -import { and, desc, eq } from 'drizzle-orm'; -import type { Database } from '../db/connection'; -import { deckPullRequests, publicDeckCards, publicDeckVersions, publicDecks } from '../db/schema'; -import { hashCard, hashVersionCards } from '../lib/hash'; -import { BadRequestError, ForbiddenError, NotFoundError } from '../lib/errors'; -import type { NotifyClient } from '../lib/notify'; - -export interface PullRequestDiffInput { - add: { type: string; fields: Record }[]; - modify: { previousContentHash: string; type: string; fields: Record }[]; - remove: { contentHash: string }[]; -} - -export interface CreatePullRequestInput { - title: string; - body?: string; - diff: PullRequestDiffInput; -} - -const SEMVER_RE = /^(\d+)\.(\d+)\.(\d+)$/; - -function bumpMinor(semver: string): string { - const m = semver.match(SEMVER_RE); - if (!m) return '1.0.0'; - return `${m[1]}.${Number(m[2]) + 1}.0`; -} - -export class PullRequestService { - constructor( - private readonly db: Database, - private readonly notify?: NotifyClient - ) {} - - async create(authorUserId: string, deckSlug: string, input: CreatePullRequestInput) { - const deck = await this.db.query.publicDecks.findFirst({ - where: eq(publicDecks.slug, deckSlug), - }); - if (!deck) throw new NotFoundError('Deck not found'); - if (deck.isTakedown) throw new ForbiddenError('Deck under takedown'); - - const total = input.diff.add.length + input.diff.modify.length + input.diff.remove.length; - if (total === 0) throw new BadRequestError('Diff is empty'); - - const [pr] = await this.db - .insert(deckPullRequests) - .values({ - deckId: deck.id, - authorUserId, - title: input.title, - body: input.body, - status: 'open', - diff: { - add: input.diff.add, - modify: input.diff.modify.map((m) => ({ - contentHash: m.previousContentHash, - fields: m.fields, - })), - remove: input.diff.remove, - }, - }) - .returning(); - - // Don't notify on self-PRs (author proposing a change to their own deck). - if (this.notify && deck.ownerUserId !== authorUserId) { - void this.notify.send({ - channel: 'email', - userId: deck.ownerUserId, - subject: `Neuer Pull Request für „${deck.title}"`, - body: `Du hast einen neuen Pull Request bekommen: „${input.title}"\n\nÖffne ${this.deckUrl(deckSlug)}, um zu reviewen.`, - data: { - type: 'cards.pr.created', - deckSlug, - prId: pr.id, - url: this.deckUrl(deckSlug), - }, - externalId: `cards.pr.created.${pr.id}`, - }); - } - - return pr; - } - - private deckUrl(slug: string): string { - const base = process.env.CARDS_WEB_URL || 'https://cardecky.mana.how'; - return `${base}/d/${slug}`; - } - - async list(deckSlug: string, status?: 'open' | 'merged' | 'closed' | 'rejected') { - const deck = await this.db.query.publicDecks.findFirst({ - where: eq(publicDecks.slug, deckSlug), - }); - if (!deck) throw new NotFoundError('Deck not found'); - - const where = status - ? and(eq(deckPullRequests.deckId, deck.id), eq(deckPullRequests.status, status)) - : eq(deckPullRequests.deckId, deck.id); - return this.db - .select() - .from(deckPullRequests) - .where(where) - .orderBy(desc(deckPullRequests.createdAt)); - } - - async get(prId: string) { - const pr = await this.db.query.deckPullRequests.findFirst({ - where: eq(deckPullRequests.id, prId), - }); - if (!pr) throw new NotFoundError('Pull request not found'); - return pr; - } - - async close(actorUserId: string, prId: string): Promise { - const pr = await this.get(prId); - const deck = await this.db.query.publicDecks.findFirst({ - where: eq(publicDecks.id, pr.deckId), - }); - if (!deck) throw new NotFoundError('Deck not found'); - // Either the deck owner or the PR author can close. - if (pr.authorUserId !== actorUserId && deck.ownerUserId !== actorUserId) { - throw new ForbiddenError('Only PR author or deck owner can close'); - } - if (pr.status !== 'open') throw new BadRequestError(`PR already ${pr.status}`); - await this.db - .update(deckPullRequests) - .set({ status: 'closed', resolvedAt: new Date() }) - .where(eq(deckPullRequests.id, prId)); - } - - async reject(actorUserId: string, prId: string): Promise { - const pr = await this.get(prId); - const deck = await this.db.query.publicDecks.findFirst({ - where: eq(publicDecks.id, pr.deckId), - }); - if (!deck) throw new NotFoundError('Deck not found'); - if (deck.ownerUserId !== actorUserId) { - throw new ForbiddenError('Only the deck owner can reject'); - } - if (pr.status !== 'open') throw new BadRequestError(`PR already ${pr.status}`); - await this.db - .update(deckPullRequests) - .set({ status: 'rejected', resolvedAt: new Date() }) - .where(eq(deckPullRequests.id, prId)); - - if (this.notify && pr.authorUserId !== actorUserId) { - void this.notify.send({ - channel: 'email', - userId: pr.authorUserId, - subject: `Pull Request „${pr.title}" abgelehnt`, - body: `Dein Pull Request für „${deck.title}" wurde abgelehnt. Siehe ${this.deckUrl(deck.slug)}.`, - data: { type: 'cards.pr.rejected', prId: pr.id, deckSlug: deck.slug }, - externalId: `cards.pr.rejected.${pr.id}`, - }); - } - } - - /** - * Merge a PR. Builds a brand-new version's card list by applying - * the PR's diff to the deck's latest version, then writes the - * usual version + cards rows and bumps `latest_version_id`. - * - * The merge happens in a single transaction so a partial failure - * doesn't leave the deck pointing at an empty version. - */ - async merge( - actorUserId: string, - prId: string, - opts: { newSemver?: string; mergeNote?: string } = {} - ) { - const pr = await this.get(prId); - if (pr.status !== 'open') throw new BadRequestError(`PR already ${pr.status}`); - - const deck = await this.db.query.publicDecks.findFirst({ - where: eq(publicDecks.id, pr.deckId), - }); - if (!deck) throw new NotFoundError('Deck not found'); - if (deck.ownerUserId !== actorUserId) { - throw new ForbiddenError('Only the deck owner can merge'); - } - if (!deck.latestVersionId) { - throw new BadRequestError('Deck has no published version yet — publish first'); - } - const latest = await this.db.query.publicDeckVersions.findFirst({ - where: eq(publicDeckVersions.id, deck.latestVersionId), - }); - if (!latest) throw new NotFoundError('Latest version row missing'); - - const newSemver = opts.newSemver ?? bumpMinor(latest.semver); - if (!SEMVER_RE.test(newSemver)) { - throw new BadRequestError(`Invalid semver: ${newSemver}`); - } - - // Pull current cards as the base for the merge. - const currentCards = await this.db - .select() - .from(publicDeckCards) - .where(eq(publicDeckCards.versionId, latest.id)) - .orderBy(publicDeckCards.ord); - - const diff = pr.diff as { - add: { type: string; fields: Record }[]; - modify: { contentHash: string; fields: Record }[]; - remove: { contentHash: string }[]; - }; - - const removedHashes = new Set(diff.remove.map((r) => r.contentHash)); - const modifyByHash = new Map(diff.modify.map((m) => [m.contentHash, m.fields])); - - const merged: { type: string; fields: Record; ord: number }[] = []; - let nextOrd = 0; - for (const c of currentCards) { - if (removedHashes.has(c.contentHash)) continue; - const replaced = modifyByHash.get(c.contentHash); - merged.push({ - type: c.type, - fields: replaced ?? (c.fields as Record), - ord: nextOrd++, - }); - } - for (const a of diff.add) { - merged.push({ type: a.type, fields: a.fields, ord: nextOrd++ }); - } - - if (merged.length === 0) { - throw new BadRequestError('Merge would result in an empty deck — refusing'); - } - - const versionContentHash = hashVersionCards(merged); - - const result = await this.db.transaction(async (tx) => { - const [version] = await tx - .insert(publicDeckVersions) - .values({ - deckId: deck.id, - semver: newSemver, - changelog: - opts.mergeNote ?? - `Merged PR: ${pr.title} (+${diff.add.length} added, ~${diff.modify.length} modified, −${diff.remove.length} removed)`, - contentHash: versionContentHash, - cardCount: merged.length, - }) - .returning(); - - await tx.insert(publicDeckCards).values( - merged.map((c) => ({ - versionId: version.id, - type: c.type as - | 'basic' - | 'basic-reverse' - | 'cloze' - | 'type-in' - | 'image-occlusion' - | 'audio' - | 'multiple-choice', - fields: c.fields, - ord: c.ord, - contentHash: hashCard({ type: c.type, fields: c.fields }), - })) - ); - - await tx - .update(publicDecks) - .set({ latestVersionId: version.id }) - .where(eq(publicDecks.id, deck.id)); - - await tx - .update(deckPullRequests) - .set({ - status: 'merged', - mergedIntoVersionId: version.id, - resolvedAt: new Date(), - }) - .where(eq(deckPullRequests.id, prId)); - - return { version }; - }); - - if (this.notify && pr.authorUserId !== actorUserId) { - void this.notify.send({ - channel: 'email', - userId: pr.authorUserId, - subject: `Pull Request „${pr.title}" gemerged`, - body: `Dein Pull Request für „${deck.title}" ist live in v${newSemver}. Danke für den Beitrag!`, - data: { - type: 'cards.pr.merged', - prId: pr.id, - deckSlug: deck.slug, - newSemver, - url: this.deckUrl(deck.slug), - }, - externalId: `cards.pr.merged.${pr.id}`, - }); - } - - return { pullRequest: { ...pr, status: 'merged' as const }, version: result.version }; - } -} diff --git a/services/cards-server/src/services/purchases.ts b/services/cards-server/src/services/purchases.ts deleted file mode 100644 index 7e8275324..000000000 --- a/services/cards-server/src/services/purchases.ts +++ /dev/null @@ -1,233 +0,0 @@ -/** - * Paid-deck purchase pipeline. Phase ζ.1 — buyer pays, author gets - * the configured share, Mana keeps the rest. Lifetime access per - * (buyer, deck) — same row covers all future versions of the deck. - * - * The flow is two-phase against mana-credits: - * - * 1. reserve(buyer, price) — atomic balance check + hold - * 2. INSERT deck_purchases row - * 3. commit(reservationId) — finalise the buyer-side debit - * 4. grant(author, authorShare) — author payout - * 5. INSERT author_payouts row - * - * If step 3 or 4 fails after the purchase row exists, we leave the - * row alone (idempotency relies on the unique (buyer, deck) index). - * A future reconciler can sweep purchase rows whose - * `creditsTransaction` is null and either commit-retry or roll back - * via a manual refund. - */ - -import { and, desc, eq } from 'drizzle-orm'; -import type { Database } from '../db/connection'; -import { - authorPayouts, - authors, - deckPurchases, - publicDecks, - publicDeckVersions, -} from '../db/schema'; -import { BadRequestError, ForbiddenError, NotFoundError } from '../lib/errors'; -import type { CreditsClient } from '../lib/credits'; -import { InsufficientCreditsError } from '../lib/credits'; -import type { NotifyClient } from '../lib/notify'; - -interface PurchaseConfig { - standardAuthorBps: number; - verifiedAuthorBps: number; -} - -export class PurchaseService { - constructor( - private readonly db: Database, - private readonly credits: CreditsClient, - private readonly config: PurchaseConfig, - private readonly notify?: NotifyClient - ) {} - - /** - * Idempotent: if the buyer already owns the deck, returns the - * existing purchase row without touching mana-credits. - */ - async purchase(buyerUserId: string, deckSlug: string) { - const deck = await this.db.query.publicDecks.findFirst({ - where: eq(publicDecks.slug, deckSlug), - }); - if (!deck) throw new NotFoundError('Deck not found'); - if (deck.isTakedown) throw new ForbiddenError('Deck under takedown'); - if (deck.priceCredits <= 0) { - throw new BadRequestError('Deck is free — no purchase required'); - } - if (deck.ownerUserId === buyerUserId) { - throw new BadRequestError('Cannot purchase your own deck'); - } - if (!deck.latestVersionId) { - throw new BadRequestError('Deck has no published version'); - } - - // Idempotency. - const existing = await this.db.query.deckPurchases.findFirst({ - where: and(eq(deckPurchases.buyerUserId, buyerUserId), eq(deckPurchases.deckId, deck.id)), - }); - if (existing) { - if (existing.refundedAt) { - throw new BadRequestError('Purchase was previously refunded'); - } - return { purchase: existing, alreadyOwned: true }; - } - - const author = await this.db.query.authors.findFirst({ - where: eq(authors.userId, deck.ownerUserId), - }); - if (!author) throw new NotFoundError('Author profile missing'); - - // Author share split — verified-mana authors get a higher cut. - const authorBps = author.verifiedMana - ? this.config.verifiedAuthorBps - : this.config.standardAuthorBps; - const authorShare = Math.floor((deck.priceCredits * authorBps) / 10_000); - const manaShare = deck.priceCredits - authorShare; - - // Step 1 — reserve. - let reservationId: string; - try { - const reservation = await this.credits.reserve({ - userId: buyerUserId, - amount: deck.priceCredits, - reason: `cards.deck-purchase:${deck.slug}`, - }); - reservationId = reservation.reservationId; - } catch (e) { - if (e instanceof InsufficientCreditsError) throw e; - throw e; - } - - // Step 2 — write the purchase row. - let purchase: typeof deckPurchases.$inferSelect; - try { - [purchase] = await this.db - .insert(deckPurchases) - .values({ - buyerUserId, - deckId: deck.id, - versionId: deck.latestVersionId, - priceCredits: deck.priceCredits, - authorShare, - manaShare, - }) - .returning(); - } catch (insertErr) { - // Rollback the reservation so the buyer's credits aren't held. - await this.credits - .refundReservation({ reservationId }) - .catch((refundErr) => - console.warn('[purchases] reservation refund after insert-fail failed', refundErr) - ); - throw insertErr; - } - - // Step 3 — commit the buyer-side debit. - try { - await this.credits.commit({ - reservationId, - description: `Cards: ${deck.title} (${deck.slug})`, - }); - } catch (commitErr) { - console.warn('[purchases] commit failed — purchase row remains for reconciler', commitErr); - throw commitErr; - } - - // Step 4 — grant the author share. Failures here don't affect - // the buyer's access (they already paid + got the row); we log - // and rely on the reconciler to retry the grant. - let payoutRow: typeof authorPayouts.$inferSelect | null = null; - if (authorShare > 0) { - try { - const granted = (await this.credits.grant({ - userId: deck.ownerUserId, - amount: authorShare, - reason: 'cards.author-payout', - referenceId: purchase.id, - description: `Cards-Verkauf: ${deck.title}`, - })) as { transactionId?: string }; - - [payoutRow] = await this.db - .insert(authorPayouts) - .values({ - authorUserId: deck.ownerUserId, - sourcePurchaseId: purchase.id, - creditsGranted: authorShare, - creditsGrantId: granted?.transactionId ?? null, - }) - .returning(); - } catch (grantErr) { - console.warn('[purchases] author grant failed — will retry via reconciler', grantErr); - } - } - - if (this.notify) { - void this.notify.send({ - channel: 'email', - userId: deck.ownerUserId, - subject: `Verkauf: „${deck.title}"`, - body: `Ein neuer Käufer hat dein Deck „${deck.title}" gekauft. Du hast ${authorShare} Credits gutgeschrieben bekommen.`, - data: { - type: 'cards.deck.purchased', - deckSlug: deck.slug, - purchaseId: purchase.id, - authorShare, - }, - externalId: `cards.deck.purchased.${purchase.id}`, - }); - } - - return { purchase, payout: payoutRow, alreadyOwned: false }; - } - - async hasPurchased(buyerUserId: string, deckId: string): Promise { - const row = await this.db.query.deckPurchases.findFirst({ - where: and(eq(deckPurchases.buyerUserId, buyerUserId), eq(deckPurchases.deckId, deckId)), - }); - return !!row && !row.refundedAt; - } - - async listForBuyer(buyerUserId: string) { - const rows = await this.db - .select({ - id: deckPurchases.id, - deckId: deckPurchases.deckId, - deckSlug: publicDecks.slug, - deckTitle: publicDecks.title, - priceCredits: deckPurchases.priceCredits, - purchasedAt: deckPurchases.purchasedAt, - refundedAt: deckPurchases.refundedAt, - versionId: deckPurchases.versionId, - versionSemver: publicDeckVersions.semver, - }) - .from(deckPurchases) - .innerJoin(publicDecks, eq(deckPurchases.deckId, publicDecks.id)) - .innerJoin(publicDeckVersions, eq(deckPurchases.versionId, publicDeckVersions.id)) - .where(eq(deckPurchases.buyerUserId, buyerUserId)) - .orderBy(desc(deckPurchases.purchasedAt)); - return rows; - } - - async listPayoutsForAuthor(authorUserId: string) { - const rows = await this.db - .select({ - id: authorPayouts.id, - purchaseId: authorPayouts.sourcePurchaseId, - creditsGranted: authorPayouts.creditsGranted, - grantedAt: authorPayouts.grantedAt, - deckSlug: publicDecks.slug, - deckTitle: publicDecks.title, - priceCredits: deckPurchases.priceCredits, - }) - .from(authorPayouts) - .innerJoin(deckPurchases, eq(authorPayouts.sourcePurchaseId, deckPurchases.id)) - .innerJoin(publicDecks, eq(deckPurchases.deckId, publicDecks.id)) - .where(eq(authorPayouts.authorUserId, authorUserId)) - .orderBy(desc(authorPayouts.grantedAt)); - return rows; - } -} diff --git a/services/cards-server/src/services/subscriptions.ts b/services/cards-server/src/services/subscriptions.ts deleted file mode 100644 index 07b1ae5c9..000000000 --- a/services/cards-server/src/services/subscriptions.ts +++ /dev/null @@ -1,266 +0,0 @@ -/** - * Subscriptions + version reads for Phase δ. - * - * `subscribe` records the user's intent and stamps the version they - * pulled at — so the client can compute a per-card diff against - * whatever the deck's `latest_version_id` is now. We don't push the - * cards back: that's the client's job (it owns the local Dexie). - * - * `versionWithCards` returns a version's cards in stable `ord` order - * so the client can replay them deterministically into its own DB. - * - * `diffSince` computes the smart-merge payload server-side: based on - * per-card `content_hash`, classify each card in the latest version - * as `unchanged | changed | added`, and list the hashes the latest - * version no longer has (`removed`). Saves the client from holding - * both versions at once. - */ - -import { and, asc, eq } from 'drizzle-orm'; -import type { Database } from '../db/connection'; -import { - deckPurchases, - deckSubscriptions, - publicDeckCards, - publicDeckVersions, - publicDecks, -} from '../db/schema'; -import { ConflictError, ForbiddenError, NotFoundError } from '../lib/errors'; - -export interface VersionPayload { - id: string; - semver: string; - contentHash: string; - publishedAt: Date; - changelog: string | null; - cards: VersionCardPayload[]; -} - -export interface VersionCardPayload { - contentHash: string; - type: string; - fields: Record; - ord: number; -} - -export interface DiffPayload { - from: string; - to: string; - added: VersionCardPayload[]; - changed: { previous: { contentHash: string }; next: VersionCardPayload }[]; - unchanged: { contentHash: string; ord: number }[]; - removed: { contentHash: string }[]; -} - -export class SubscriptionService { - constructor(private readonly db: Database) {} - - async subscribe(userId: string, deckSlug: string) { - const deck = await this.db.query.publicDecks.findFirst({ - where: eq(publicDecks.slug, deckSlug), - }); - if (!deck) throw new NotFoundError('Deck not found'); - if (deck.isTakedown) throw new ForbiddenError('Deck under takedown'); - if (!deck.latestVersionId) throw new ConflictError('Deck has no published version yet'); - // Paid decks need a non-refunded purchase before the user can - // subscribe (= pull the cards). The author themselves can - // always subscribe to their own paid deck for testing. - if (deck.priceCredits > 0 && deck.ownerUserId !== userId) { - const purchase = await this.db.query.deckPurchases.findFirst({ - where: and(eq(deckPurchases.buyerUserId, userId), eq(deckPurchases.deckId, deck.id)), - }); - if (!purchase || purchase.refundedAt) { - throw new ForbiddenError('Paid deck — purchase required before subscribing'); - } - } - - await this.db - .insert(deckSubscriptions) - .values({ - userId, - deckId: deck.id, - currentVersionId: deck.latestVersionId, - }) - .onConflictDoUpdate({ - target: [deckSubscriptions.userId, deckSubscriptions.deckId], - set: { currentVersionId: deck.latestVersionId }, - }); - - return { deckSlug, latestVersionId: deck.latestVersionId }; - } - - async unsubscribe(userId: string, deckSlug: string) { - const deck = await this.db.query.publicDecks.findFirst({ - where: eq(publicDecks.slug, deckSlug), - }); - if (!deck) throw new NotFoundError('Deck not found'); - await this.db - .delete(deckSubscriptions) - .where(and(eq(deckSubscriptions.userId, userId), eq(deckSubscriptions.deckId, deck.id))); - } - - async listForUser(userId: string) { - const rows = await this.db - .select({ - deckSlug: publicDecks.slug, - deckTitle: publicDecks.title, - deckDescription: publicDecks.description, - deckLatestVersionId: publicDecks.latestVersionId, - subscribedAt: deckSubscriptions.subscribedAt, - notifyUpdates: deckSubscriptions.notifyUpdates, - currentVersionId: deckSubscriptions.currentVersionId, - }) - .from(deckSubscriptions) - .innerJoin(publicDecks, eq(publicDecks.id, deckSubscriptions.deckId)) - .where(eq(deckSubscriptions.userId, userId)) - .orderBy(deckSubscriptions.subscribedAt); - - return rows.map((r) => ({ - deckSlug: r.deckSlug, - deckTitle: r.deckTitle, - deckDescription: r.deckDescription, - subscribedAt: r.subscribedAt, - notifyUpdates: r.notifyUpdates, - currentVersionId: r.currentVersionId, - latestVersionId: r.deckLatestVersionId, - updateAvailable: - r.deckLatestVersionId !== null && r.currentVersionId !== r.deckLatestVersionId, - })); - } - - async versionWithCards(deckSlug: string, semver: string): Promise { - const deck = await this.db.query.publicDecks.findFirst({ - where: eq(publicDecks.slug, deckSlug), - }); - if (!deck) throw new NotFoundError('Deck not found'); - const version = await this.db.query.publicDeckVersions.findFirst({ - where: and(eq(publicDeckVersions.deckId, deck.id), eq(publicDeckVersions.semver, semver)), - }); - if (!version) throw new NotFoundError(`Version ${semver} not found`); - - const cards = await this.db - .select() - .from(publicDeckCards) - .where(eq(publicDeckCards.versionId, version.id)) - .orderBy(asc(publicDeckCards.ord)); - - return { - id: version.id, - semver: version.semver, - contentHash: version.contentHash, - publishedAt: version.publishedAt, - changelog: version.changelog, - cards: cards.map((c) => ({ - contentHash: c.contentHash, - type: c.type, - fields: c.fields as Record, - ord: c.ord, - })), - }; - } - - /** Smart-merge diff: tell the client what changed since `fromSemver`. */ - async diffSince(deckSlug: string, fromSemver: string): Promise { - const deck = await this.db.query.publicDecks.findFirst({ - where: eq(publicDecks.slug, deckSlug), - }); - if (!deck) throw new NotFoundError('Deck not found'); - if (!deck.latestVersionId) throw new NotFoundError('Deck has no published version'); - - const latestVersion = await this.db.query.publicDeckVersions.findFirst({ - where: eq(publicDeckVersions.id, deck.latestVersionId), - }); - if (!latestVersion) throw new NotFoundError('Latest version row missing'); - - const fromVersion = await this.db.query.publicDeckVersions.findFirst({ - where: and(eq(publicDeckVersions.deckId, deck.id), eq(publicDeckVersions.semver, fromSemver)), - }); - if (!fromVersion) throw new NotFoundError(`Version ${fromSemver} not found`); - - // Empty diff if already on latest. - if (fromVersion.id === latestVersion.id) { - return { - from: fromSemver, - to: latestVersion.semver, - added: [], - changed: [], - unchanged: [], - removed: [], - }; - } - - const [fromCards, toCards] = await Promise.all([ - this.db - .select({ contentHash: publicDeckCards.contentHash, ord: publicDeckCards.ord }) - .from(publicDeckCards) - .where(eq(publicDeckCards.versionId, fromVersion.id)), - this.db - .select() - .from(publicDeckCards) - .where(eq(publicDeckCards.versionId, latestVersion.id)) - .orderBy(asc(publicDeckCards.ord)), - ]); - - const fromHashes = new Set(fromCards.map((c) => c.contentHash)); - const toHashes = new Set(toCards.map((c) => c.contentHash)); - - // Cards that are still here verbatim. - const unchanged: { contentHash: string; ord: number }[] = []; - // Brand-new cards (hash not in `from`). - const added: VersionCardPayload[] = []; - // Cards in `from` that vanished completely. - const removed: { contentHash: string }[] = fromCards - .filter((c) => !toHashes.has(c.contentHash)) - .map((c) => ({ contentHash: c.contentHash })); - - // `changed` is hard to detect without a stable card-id across - // versions. We approximate by treating ord-position pairs that - // neither match nor are in the unchanged set: an "added" at the - // same ord as a "removed" → changed. Phase ε's pull-request - // pipeline gives us a real card-lineage; until then this - // heuristic is good enough. - const changed: { previous: { contentHash: string }; next: VersionCardPayload }[] = []; - const removedByOrd = new Map(); - for (const c of fromCards) { - if (!toHashes.has(c.contentHash)) removedByOrd.set(c.ord, c.contentHash); - } - - for (const c of toCards) { - if (fromHashes.has(c.contentHash)) { - unchanged.push({ contentHash: c.contentHash, ord: c.ord }); - } else if (removedByOrd.has(c.ord)) { - const prevHash = removedByOrd.get(c.ord)!; - removedByOrd.delete(c.ord); - changed.push({ - previous: { contentHash: prevHash }, - next: { - contentHash: c.contentHash, - type: c.type, - fields: c.fields as Record, - ord: c.ord, - }, - }); - } else { - added.push({ - contentHash: c.contentHash, - type: c.type, - fields: c.fields as Record, - ord: c.ord, - }); - } - } - - // Anything left in removedByOrd is a real removal (not paired up - // with a `changed`). - const trueRemoved = removed.filter((r) => [...removedByOrd.values()].includes(r.contentHash)); - - return { - from: fromSemver, - to: latestVersion.semver, - added, - changed, - unchanged, - removed: trueRemoved, - }; - } -} diff --git a/services/cards-server/tsconfig.json b/services/cards-server/tsconfig.json deleted file mode 100644 index a432166c5..000000000 --- a/services/cards-server/tsconfig.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "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"] -}