chore(decommission): remove services/cards-server/

Marketplace-Backend war an apps/cards gekoppelt — beides ist
jetzt im standalone-Repo git.mana.how/till/cards. Live auf
cardecky-api.mana.how (apps/api in cards-Repo).

Rollback: git checkout cards-decommission-base -- services/cards-server/

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-05-08 20:27:33 +02:00
parent 9cd8717494
commit bc158cb0bc
52 changed files with 0 additions and 8012 deletions

View file

@ -1,5 +0,0 @@
node_modules
.env
.env.local
.DS_Store
drizzle/meta/_journal.json.bak

View file

@ -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".

View file

@ -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"]

View file

@ -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.

View file

@ -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'],
});

View file

@ -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");

View file

@ -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');

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -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
}
]
}

View file

@ -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"
}
}

View file

@ -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),
},
};
}

View file

@ -1,23 +0,0 @@
import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
import * as schema from './schema';
let _db: ReturnType<typeof drizzle<typeof schema>> | 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<typeof getDb>;

View file

@ -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');

View file

@ -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),
})
);

View file

@ -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),
})
);

View file

@ -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<Record<string, string>>().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),
})
);

View file

@ -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<string, string> }[];
modify: { contentHash: string; fields: Record<string, string> }[];
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<PullRequestDiff>().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),
})
);

View file

@ -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),
})
);

View file

@ -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';

View file

@ -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),
})
);

View file

@ -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),
})
);

View file

@ -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,
};

View file

@ -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<string, string> }[];
}
const MODEL = process.env.AI_MODERATION_MODEL || 'gpt-4o-mini';
const MAX_CARDS_FOR_PROMPT = 50;
function buildPrompt(input: ModerationInput): string {
const sample = input.cards.slice(0, MAX_CARDS_FOR_PROMPT).map((c, i) => {
const fieldsStr = Object.entries(c.fields)
.map(([k, v]) => ` ${k}: ${v}`)
.join('\n');
return `Karte ${i + 1}:\n${fieldsStr}`;
});
return [
`Deck-Titel: ${input.title}`,
input.description ? `Beschreibung: ${input.description}` : '',
`Karten (${input.cards.length} insgesamt, erste ${sample.length} gezeigt):`,
...sample,
]
.filter(Boolean)
.join('\n\n');
}
function failOpen(rationale: string): ModerationVerdict {
return {
verdict: 'flag',
categories: ['_internal'],
rationale: `AI-Mod fail-open: ${rationale}`,
model: MODEL,
};
}
function stripCodeFences(s: string): string {
return s
.replace(/^\s*```(?:json)?\s*/i, '')
.replace(/\s*```\s*$/i, '')
.trim();
}
export async function moderateDeckContent(
input: ModerationInput,
llmUrl: string
): Promise<ModerationVerdict> {
let res: Response;
try {
res = await fetch(`${llmUrl}/v1/chat/completions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: MODEL,
temperature: 0,
messages: [
{ role: 'system', content: SYSTEM_PROMPT },
{ role: 'user', content: buildPrompt(input) },
],
}),
});
} catch (e) {
return failOpen(`network: ${(e as Error).message}`);
}
if (!res.ok) return failOpen(`http ${res.status}`);
const json = (await res.json().catch(() => null)) as {
choices?: { message?: { content?: string } }[];
} | null;
const raw = json?.choices?.[0]?.message?.content?.trim();
if (!raw) return failOpen('empty response');
let parsed: { verdict?: unknown; categories?: unknown; rationale?: unknown };
try {
parsed = JSON.parse(stripCodeFences(raw));
} catch {
return failOpen('invalid JSON');
}
const verdict =
parsed.verdict === 'pass' || parsed.verdict === 'flag' || parsed.verdict === 'block'
? parsed.verdict
: 'flag';
const categories = Array.isArray(parsed.categories)
? parsed.categories.filter((c): c is string => typeof c === 'string')
: [];
const rationale = typeof parsed.rationale === 'string' ? parsed.rationale : '';
return { verdict, categories, rationale, model: MODEL };
}

View file

@ -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<unknown>;
refundReservation(input: { reservationId: string }): Promise<unknown>;
grant(input: {
userId: string;
amount: number;
reason: string;
referenceId: string;
description?: string;
}): Promise<{ transactionId?: string; grantId?: string } | unknown>;
}
export function createCreditsClient(opts: { url: string; serviceKey: string }): CreditsClient {
async function call<T>(path: string, body: unknown): Promise<T> {
const res = await fetch(`${opts.url}/api/v1/internal${path}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Service-Key': opts.serviceKey,
},
body: JSON.stringify(body),
});
if (!res.ok) {
let msg = `${res.status} ${res.statusText}`;
let code = 'credits_error';
try {
const j = (await res.json()) as { code?: string; message?: string };
if (j.message) msg = j.message;
if (j.code) code = j.code;
} catch {
/* keep default */
}
if (res.status === 402 || code === 'insufficient_credits') {
throw new InsufficientCreditsError(msg);
}
throw new CreditsClientError(res.status, code, msg);
}
return (await res.json()) as T;
}
return {
reserve: (input) => call('/credits/reserve', input),
commit: (input) => call('/credits/commit', input),
refundReservation: (input) => call('/credits/refund-reservation', input),
grant: (input) => call('/credits/grant', input),
};
}

View file

@ -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 };

View file

@ -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<string, unknown> = {};
for (const key of Object.keys(value as Record<string, unknown>).sort()) {
sorted[key] = canonical((value as Record<string, unknown>)[key]);
}
return sorted;
}
function sha256(input: string): string {
return createHash('sha256').update(input).digest('hex');
}
/** Hash for a single card — based on (type, fields). */
export function hashCard(card: { type: string; fields: Record<string, string> }): string {
return sha256(JSON.stringify(canonical({ type: card.type, fields: card.fields })));
}
/**
* Hash for an ordered list of cards version content hash. Order
* matters because re-ordering is a meaningful change for the learner.
*/
export function hashVersionCards(
cards: { type: string; fields: Record<string, string>; ord: number }[]
): string {
const ordered = [...cards].sort((a, b) => a.ord - b.ord);
return sha256(
JSON.stringify(ordered.map((c) => canonical({ type: c.type, fields: c.fields, ord: c.ord })))
);
}

View file

@ -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<string, unknown>;
externalId?: string;
}
interface NotifyClient {
send(input: SendInput): Promise<void>;
}
export function createNotifyClient(opts: { url: string; serviceKey: string }): NotifyClient {
return {
async send(input) {
try {
await fetch(`${opts.url}/api/v1/notifications/send`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Service-Key': opts.serviceKey,
},
body: JSON.stringify({
channel: input.channel,
appId: 'cards',
userId: input.userId,
subject: input.subject,
body: input.body,
data: input.data,
externalId: input.externalId,
}),
});
} catch (err) {
console.warn('[cards-server] notify failed', err);
}
},
};
}
export type { NotifyClient };

View file

@ -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 };
}

View file

@ -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<typeof createRemoteJWKSet> | null = null;
function getJwks(authUrl: string) {
if (!jwks) {
jwks = createRemoteJWKSet(new URL('/api/auth/jwks', authUrl));
}
return jwks;
}
export interface AuthUser {
userId: string;
email: string;
role: string;
}
export function jwtAuth(authUrl: string): MiddlewareHandler {
return async (c, next) => {
const authHeader = c.req.header('Authorization');
if (!authHeader?.startsWith('Bearer ')) {
throw new UnauthorizedError('Missing or invalid Authorization header');
}
const token = authHeader.slice(7);
try {
const { payload } = await jwtVerify(token, getJwks(authUrl), {
issuer: authUrl,
audience: 'mana',
});
const user: AuthUser = {
userId: payload.sub || '',
email: (payload.email as string) || '',
role: (payload.role as string) || 'user',
};
c.set('user', user);
await next();
} catch {
throw new UnauthorizedError('Invalid or expired token');
}
};
}

View file

@ -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<typeof createRemoteJWKSet> | null = null;
function getJwks(authUrl: string) {
if (!jwks) jwks = createRemoteJWKSet(new URL('/api/auth/jwks', authUrl));
return jwks;
}
export function optionalAuth(authUrl: string): MiddlewareHandler {
return async (c, next) => {
const authHeader = c.req.header('Authorization');
if (!authHeader?.startsWith('Bearer ')) {
await next();
return;
}
const token = authHeader.slice(7);
try {
const { payload } = await jwtVerify(token, getJwks(authUrl), {
issuer: authUrl,
audience: 'mana',
});
const user: AuthUser = {
userId: payload.sub || '',
email: (payload.email as string) || '',
role: (payload.role as string) || 'user',
};
c.set('user', user);
} catch {
// Bad token = anonymous; the strict middleware rejects on
// write paths.
}
await next();
};
}

View file

@ -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();
};
}

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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(),
});
});

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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=<semver> required, e.g. ?from=1.0.0');
}
const diff = await service.diffSince(c.req.param('slug'), from);
return c.json(diff);
});
return router;
}

View file

@ -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;
}
}

View file

@ -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<string, string>;
}[];
}
export interface PublishResult {
deck: typeof publicDecks.$inferSelect;
version: typeof publicDeckVersions.$inferSelect;
moderation: { verdict: 'pass' | 'flag' | 'block'; categories: string[] };
}
const SEMVER_RE = /^(\d+)\.(\d+)\.(\d+)$/;
function validatePrice(price: number, license: string) {
if (price < 0) throw new BadRequestError('priceCredits cannot be negative');
if (price > 0 && license !== 'Cardecky-Pro-Only-1.0') {
throw new BadRequestError('Paid decks must use the Cardecky-Pro-Only-1.0 license');
}
}
export class DeckService {
constructor(
private readonly db: Database,
private readonly llmUrl: string
) {}
async init(ownerUserId: string, input: InitDeckInput) {
const validation = validateSlug(input.slug);
if (!validation.ok) throw new BadRequestError(`Slug invalid: ${validation.reason}`);
const license = input.license ?? 'Cardecky-Personal-Use-1.0';
const priceCredits = input.priceCredits ?? 0;
validatePrice(priceCredits, license);
const existing = await this.db.query.publicDecks.findFirst({
where: eq(publicDecks.slug, input.slug),
});
if (existing) throw new ConflictError('Slug already taken');
const [created] = await this.db
.insert(publicDecks)
.values({
slug: input.slug,
title: input.title,
description: input.description,
language: input.language,
license,
priceCredits,
ownerUserId,
})
.returning();
return created;
}
async getBySlug(slug: string) {
const deck = await this.db.query.publicDecks.findFirst({
where: eq(publicDecks.slug, slug),
});
if (!deck) throw new NotFoundError('Deck not found');
const version = deck.latestVersionId
? await this.db.query.publicDeckVersions.findFirst({
where: eq(publicDeckVersions.id, deck.latestVersionId),
})
: null;
return { deck, latestVersion: version };
}
async publish(ownerUserId: string, slug: string, input: PublishInput): Promise<PublishResult> {
if (!SEMVER_RE.test(input.semver)) {
throw new BadRequestError('semver must look like 1.0.0');
}
if (input.cards.length === 0) {
throw new BadRequestError('A version needs at least one card');
}
const deck = await this.db.query.publicDecks.findFirst({
where: eq(publicDecks.slug, slug),
});
if (!deck) throw new NotFoundError('Deck not found');
if (deck.ownerUserId !== ownerUserId) {
throw new ForbiddenError('Only the deck owner can publish');
}
if (deck.isTakedown) throw new ForbiddenError('Deck is under takedown');
// semver must be strictly greater than the latest published
// version so version history stays linear.
if (deck.latestVersionId) {
const latest = await this.db.query.publicDeckVersions.findFirst({
where: eq(publicDeckVersions.id, deck.latestVersionId),
});
if (latest && !semverGreater(input.semver, latest.semver)) {
throw new ConflictError(`semver ${input.semver} must be > ${latest.semver}`);
}
}
// 1) AI moderation first-pass.
const moderation = await moderateDeckContent(
{
title: deck.title,
description: deck.description ?? undefined,
cards: input.cards.map((c) => ({ fields: c.fields })),
},
this.llmUrl
);
if (moderation.verdict === 'block') {
throw new ForbiddenError(
`Refused by content moderation: ${moderation.rationale || 'no rationale'}`
);
}
// 2) Compute hashes.
const cardsWithOrd = input.cards.map((c, i) => ({ ...c, ord: i }));
const versionContentHash = hashVersionCards(cardsWithOrd);
// 3) Insert version + cards + flip latest_version_id atomically.
const result = await this.db.transaction(async (tx) => {
const [version] = await tx
.insert(publicDeckVersions)
.values({
deckId: deck.id,
semver: input.semver,
changelog: input.changelog,
contentHash: versionContentHash,
cardCount: cardsWithOrd.length,
})
.returning();
await tx.insert(publicDeckCards).values(
cardsWithOrd.map((c) => ({
versionId: version.id,
type: c.type,
fields: c.fields,
ord: c.ord,
contentHash: hashCard(c),
}))
);
await tx.insert(aiModerationLog).values({
versionId: version.id,
verdict: moderation.verdict,
categories: moderation.categories,
model: moderation.model,
rationale: moderation.rationale,
});
const [updatedDeck] = await tx
.update(publicDecks)
.set({ latestVersionId: version.id })
.where(and(eq(publicDecks.id, deck.id)))
.returning();
return { deck: updatedDeck, version };
});
return {
deck: result.deck,
version: result.version,
moderation: { verdict: moderation.verdict, categories: moderation.categories },
};
}
}
function semverGreater(a: string, b: string): boolean {
const matchA = a.match(SEMVER_RE);
const matchB = b.match(SEMVER_RE);
if (!matchA || !matchB) return false;
for (let i = 1; i <= 3; i++) {
const da = Number.parseInt(matchA[i], 10);
const db = Number.parseInt(matchB[i], 10);
if (da > db) return true;
if (da < db) return false;
}
return false;
}
// Silence unused-binding lint for `sql` import — we keep it ready for
// upcoming routes (server-side orderings / counts).
void sql;

View file

@ -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<Record<string, number>> {
const deck = await this.db.query.publicDecks.findFirst({
where: eq(publicDecks.slug, deckSlug),
});
if (!deck) throw new NotFoundError('Deck not found');
const rows = await this.db
.select({
contentHash: cardDiscussions.cardContentHash,
count: sql<number>`count(*)::int`.as('count'),
})
.from(cardDiscussions)
.where(and(eq(cardDiscussions.deckId, deck.id), eq(cardDiscussions.hidden, false)))
.groupBy(cardDiscussions.cardContentHash);
const out: Record<string, number> = {};
for (const r of rows) out[r.contentHash] = r.count;
return out;
}
async listForCard(cardContentHash: string) {
const rows = await this.db
.select()
.from(cardDiscussions)
.where(
and(eq(cardDiscussions.cardContentHash, cardContentHash), eq(cardDiscussions.hidden, false))
)
.orderBy(asc(cardDiscussions.createdAt));
return rows;
}
async hide(actorUserId: string, discussionId: string) {
const row = await this.db.query.cardDiscussions.findFirst({
where: eq(cardDiscussions.id, discussionId),
});
if (!row) throw new NotFoundError('Discussion not found');
const deck = await this.db.query.publicDecks.findFirst({
where: eq(publicDecks.id, row.deckId),
});
if (!deck) throw new NotFoundError('Deck not found');
// Author of the comment OR deck owner can hide.
if (row.authorUserId !== actorUserId && deck.ownerUserId !== actorUserId) {
throw new ForbiddenError('Not allowed to hide this comment');
}
await this.db
.update(cardDiscussions)
.set({ hidden: true })
.where(eq(cardDiscussions.id, discussionId));
}
}

View file

@ -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<boolean> {
const row = await this.db
.select({ id: deckStars.deckId })
.from(deckStars)
.innerJoin(publicDecks, eq(publicDecks.id, deckStars.deckId))
.where(and(eq(deckStars.userId, userId), eq(publicDecks.slug, deckSlug)))
.limit(1);
return row.length > 0;
}
async followAuthor(followerUserId: string, authorSlug: string) {
const author = await this.db.query.authors.findFirst({
where: eq(authors.slug, authorSlug),
});
if (!author) throw new NotFoundError('Author not found');
if (author.userId === followerUserId) {
throw new ConflictError('You cannot follow yourself');
}
await this.db
.insert(authorFollows)
.values({ followerUserId, authorUserId: author.userId })
.onConflictDoNothing();
}
async unfollowAuthor(followerUserId: string, authorSlug: string) {
const author = await this.db.query.authors.findFirst({
where: eq(authors.slug, authorSlug),
});
if (!author) throw new NotFoundError('Author not found');
await this.db
.delete(authorFollows)
.where(
and(
eq(authorFollows.followerUserId, followerUserId),
eq(authorFollows.authorUserId, author.userId)
)
);
}
async isFollowing(followerUserId: string, authorSlug: string): Promise<boolean> {
const row = await this.db
.select({ id: authorFollows.authorUserId })
.from(authorFollows)
.innerJoin(authors, eq(authors.userId, authorFollows.authorUserId))
.where(and(eq(authorFollows.followerUserId, followerUserId), eq(authors.slug, authorSlug)))
.limit(1);
return row.length > 0;
}
}

View file

@ -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<string>`(SELECT user_id FROM cards.authors WHERE slug = ${filter.authorSlug} LIMIT 1)`
)
);
}
if (filter.tag) {
conditions.push(
sql`EXISTS (SELECT 1 FROM cards.deck_tags dt JOIN cards.tag_definitions td ON td.id = dt.tag_id WHERE dt.deck_id = ${publicDecks.id} AND td.slug = ${filter.tag})`
);
}
// Pre-compute counts via subqueries; avoids N+1.
const starCount = sql<number>`(SELECT count(*)::int FROM cards.deck_stars s WHERE s.deck_id = ${publicDecks.id})`;
const subscriberCount = sql<number>`(SELECT count(*)::int FROM cards.deck_subscriptions s WHERE s.deck_id = ${publicDecks.id})`;
const cardCountExpr = sql<number>`COALESCE((SELECT v.card_count FROM cards.deck_versions v WHERE v.id = ${publicDecks.latestVersionId}), 0)`;
const sortClause =
sort === 'popular'
? desc(starCount)
: sort === 'trending'
? desc(
sql<number>`(SELECT count(*)::int FROM cards.deck_stars s WHERE s.deck_id = ${publicDecks.id} AND s.starred_at >= now() - interval '7 days')`
)
: desc(publicDecks.createdAt);
const baseQuery = this.db
.select({
slug: publicDecks.slug,
title: publicDecks.title,
description: publicDecks.description,
language: publicDecks.language,
license: publicDecks.license,
priceCredits: publicDecks.priceCredits,
cardCount: cardCountExpr,
starCount,
subscriberCount,
isFeatured: publicDecks.isFeatured,
createdAt: publicDecks.createdAt,
ownerSlug: authors.slug,
ownerDisplayName: authors.displayName,
ownerVerifiedMana: authors.verifiedMana,
ownerVerifiedCommunity: authors.verifiedCommunity,
})
.from(publicDecks)
.innerJoin(authors, eq(authors.userId, publicDecks.ownerUserId))
.where(and(...conditions))
.orderBy(sortClause)
.limit(limit)
.offset(offset);
const totalQuery = this.db
.select({ value: count() })
.from(publicDecks)
.innerJoin(authors, eq(authors.userId, publicDecks.ownerUserId))
.where(and(...conditions));
const [rows, totalResult] = await Promise.all([baseQuery, totalQuery]);
return {
items: rows.map((r) => ({
slug: r.slug,
title: r.title,
description: r.description,
language: r.language,
license: r.license,
priceCredits: r.priceCredits,
cardCount: Number(r.cardCount),
starCount: Number(r.starCount),
subscriberCount: Number(r.subscriberCount),
isFeatured: r.isFeatured,
createdAt: r.createdAt,
owner: {
slug: r.ownerSlug,
displayName: r.ownerDisplayName,
verifiedMana: r.ownerVerifiedMana,
verifiedCommunity: r.ownerVerifiedCommunity,
},
})),
total: totalResult[0]?.value ?? 0,
};
}
/** Featured + Trending side-by-side for the /explore landing. */
async explore(): Promise<{ featured: DeckListEntry[]; trending: DeckListEntry[] }> {
const [featuredResult, trendingResult] = await Promise.all([
this.browse({ sort: 'popular', limit: 8 }).then((r) =>
r.items.filter((d) => d.isFeatured).slice(0, 8)
),
this.browse({ sort: 'trending', limit: 8 }),
]);
return { featured: featuredResult, trending: trendingResult.items };
}
async tagTree() {
const rows = await this.db
.select()
.from(tagDefinitions)
.orderBy(tagDefinitions.parentId, tagDefinitions.name);
return rows;
}
async curatedTagsOnly() {
return this.db
.select()
.from(tagDefinitions)
.where(eq(tagDefinitions.curated, true))
.orderBy(tagDefinitions.name);
}
// Silence unused-binding lint for imports that downstream queries
// will pull in.
_keepAlive() {
void deckSubscriptions;
void deckStars;
void deckTags;
void publicDeckVersions;
void isNull;
void gte;
}
}

View file

@ -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<ReportCategory>([
'spam',
'copyright',
'nsfw',
'misinformation',
'hate',
'other',
]);
export class ModerationService {
constructor(
private readonly db: Database,
private readonly notify?: NotifyClient
) {}
async createReport(reporterUserId: string, input: CreateReportInput) {
if (!VALID_CATEGORIES.has(input.category)) {
throw new BadRequestError(`Unknown report category: ${input.category}`);
}
const deck = await this.db.query.publicDecks.findFirst({
where: eq(publicDecks.slug, input.deckSlug),
});
if (!deck) throw new NotFoundError('Deck not found');
const [row] = await this.db
.insert(deckReports)
.values({
deckId: deck.id,
versionId: deck.latestVersionId ?? null,
cardContentHash: input.cardContentHash ?? null,
reporterUserId,
category: input.category,
body: input.body ?? null,
})
.returning();
return row;
}
async listOpen(limit = 50) {
return this.db
.select({
id: deckReports.id,
deckId: deckReports.deckId,
deckSlug: publicDecks.slug,
deckTitle: publicDecks.title,
cardContentHash: deckReports.cardContentHash,
reporterUserId: deckReports.reporterUserId,
category: deckReports.category,
body: deckReports.body,
status: deckReports.status,
createdAt: deckReports.createdAt,
})
.from(deckReports)
.innerJoin(publicDecks, eq(deckReports.deckId, publicDecks.id))
.where(eq(deckReports.status, 'open'))
.orderBy(desc(deckReports.createdAt))
.limit(limit);
}
async resolveReport(adminUserId: string, reportId: string, input: ResolveReportInput) {
const report = await this.db.query.deckReports.findFirst({
where: eq(deckReports.id, reportId),
});
if (!report) throw new NotFoundError('Report not found');
if (report.status !== 'open') {
throw new BadRequestError(`Report already ${report.status}`);
}
const deck = await this.db.query.publicDecks.findFirst({
where: eq(publicDecks.id, report.deckId),
});
if (!deck) throw new NotFoundError('Deck disappeared');
if (input.action === 'dismiss') {
await this.markResolved(reportId, adminUserId, 'dismissed', input.notes);
return { action: 'dismissed' as const };
}
if (input.action === 'takedown') {
await this.takedownDeck(adminUserId, deck.slug, input.notes);
await this.markResolved(reportId, adminUserId, 'actioned', input.notes);
return { action: 'takedown' as const };
}
if (input.action === 'ban-author') {
await this.banAuthor(adminUserId, deck.ownerUserId, input.notes);
// A banned author's decks get taken down too — saves a click.
await this.takedownDeck(adminUserId, deck.slug, input.notes ?? 'Author banned');
await this.markResolved(reportId, adminUserId, 'actioned', input.notes);
return { action: 'ban-author' as const };
}
throw new BadRequestError(`Unknown action: ${input.action as string}`);
}
private async markResolved(
reportId: string,
adminUserId: string,
status: 'dismissed' | 'actioned',
notes: string | undefined
) {
await this.db
.update(deckReports)
.set({
status,
resolvedBy: adminUserId,
resolvedAt: new Date(),
resolutionNotes: notes ?? null,
})
.where(eq(deckReports.id, reportId));
}
async takedownDeck(adminUserId: string, deckSlug: string, reason?: string) {
const deck = await this.db.query.publicDecks.findFirst({
where: eq(publicDecks.slug, deckSlug),
});
if (!deck) throw new NotFoundError('Deck not found');
if (deck.isTakedown) return { alreadyDown: true };
await this.db.transaction(async (tx) => {
await tx
.update(publicDecks)
.set({
isTakedown: true,
takedownAt: new Date(),
takedownReason: reason ?? 'Moderation action',
})
.where(eq(publicDecks.id, deck.id));
// Auto-close any other open reports against the same deck.
await tx
.update(deckReports)
.set({
status: 'actioned',
resolvedBy: adminUserId,
resolvedAt: new Date(),
resolutionNotes: 'Auto-closed by takedown',
})
.where(and(eq(deckReports.deckId, deck.id), eq(deckReports.status, 'open')));
// Open PRs against the deck are no longer mergeable; mark them
// closed so authors / contributors see clear state.
await tx
.update(deckPullRequests)
.set({
status: 'closed',
resolvedAt: new Date(),
})
.where(and(eq(deckPullRequests.deckId, deck.id), eq(deckPullRequests.status, 'open')));
});
if (this.notify) {
void this.notify.send({
channel: 'email',
userId: deck.ownerUserId,
subject: `Dein Deck „${deck.title}" wurde entfernt`,
body: `Dein Deck „${deck.title}" wurde von der Moderation entfernt.${
reason ? `\n\nGrund: ${reason}` : ''
}\n\nDu hast 30 Tage Zeit, gegen die Entscheidung Einspruch einzulegen.`,
data: {
type: 'cards.deck.takedown',
deckSlug: deck.slug,
reason: reason ?? null,
},
externalId: `cards.deck.takedown.${deck.id}`,
});
}
return { alreadyDown: false };
}
async banAuthor(adminUserId: string, targetUserId: string, reason?: string) {
const author = await this.db.query.authors.findFirst({
where: eq(authors.userId, targetUserId),
});
if (!author) throw new NotFoundError('Author not found');
if (author.bannedAt) return { alreadyBanned: true };
await this.db
.update(authors)
.set({ bannedAt: new Date() })
.where(eq(authors.userId, targetUserId));
// Take down every deck owned by the banned author.
const banned = await this.db
.select({ slug: publicDecks.slug })
.from(publicDecks)
.where(and(eq(publicDecks.ownerUserId, targetUserId), eq(publicDecks.isTakedown, false)));
for (const d of banned) {
await this.takedownDeck(adminUserId, d.slug, reason ?? 'Author banned');
}
return { alreadyBanned: false };
}
async setVerifiedMana(adminUserId: string, authorSlug: string, verified: boolean) {
void adminUserId;
const author = await this.db.query.authors.findFirst({
where: eq(authors.slug, authorSlug),
});
if (!author) throw new NotFoundError('Author not found');
await this.db
.update(authors)
.set({ verifiedMana: verified })
.where(eq(authors.userId, author.userId));
if (this.notify) {
void this.notify.send({
channel: 'email',
userId: author.userId,
subject: verified ? '🛡️ Du bist jetzt Mana-Verifiziert' : 'Mana-Verifizierung entzogen',
body: verified
? 'Mana-e.V. hat dich als verifizierten Author bestätigt. Dein Author-Cut steigt von 80% auf 90%.'
: 'Deine Mana-Verifizierung wurde entzogen. Bei Fragen: kontakt@mana.how.',
data: {
type: 'cards.author.verified',
authorSlug,
verified,
},
externalId: `cards.author.verified.${author.userId}.${verified ? '1' : '0'}.${Date.now()}`,
});
}
return { authorSlug, verifiedMana: verified };
}
/**
* Lift a takedown used during appeals. Reports stay closed.
*/
async restoreDeck(adminUserId: string, deckSlug: string) {
void adminUserId;
const deck = await this.db.query.publicDecks.findFirst({
where: eq(publicDecks.slug, deckSlug),
});
if (!deck) throw new NotFoundError('Deck not found');
if (!deck.isTakedown) throw new BadRequestError('Deck is not under takedown');
await this.db
.update(publicDecks)
.set({ isTakedown: false, takedownAt: null, takedownReason: null })
.where(eq(publicDecks.id, deck.id));
void isNull;
return { restored: true };
}
}

View file

@ -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<string, string> }[];
modify: { previousContentHash: string; type: string; fields: Record<string, string> }[];
remove: { contentHash: string }[];
}
export interface CreatePullRequestInput {
title: string;
body?: string;
diff: PullRequestDiffInput;
}
const SEMVER_RE = /^(\d+)\.(\d+)\.(\d+)$/;
function bumpMinor(semver: string): string {
const m = semver.match(SEMVER_RE);
if (!m) return '1.0.0';
return `${m[1]}.${Number(m[2]) + 1}.0`;
}
export class PullRequestService {
constructor(
private readonly db: Database,
private readonly notify?: NotifyClient
) {}
async create(authorUserId: string, deckSlug: string, input: CreatePullRequestInput) {
const deck = await this.db.query.publicDecks.findFirst({
where: eq(publicDecks.slug, deckSlug),
});
if (!deck) throw new NotFoundError('Deck not found');
if (deck.isTakedown) throw new ForbiddenError('Deck under takedown');
const total = input.diff.add.length + input.diff.modify.length + input.diff.remove.length;
if (total === 0) throw new BadRequestError('Diff is empty');
const [pr] = await this.db
.insert(deckPullRequests)
.values({
deckId: deck.id,
authorUserId,
title: input.title,
body: input.body,
status: 'open',
diff: {
add: input.diff.add,
modify: input.diff.modify.map((m) => ({
contentHash: m.previousContentHash,
fields: m.fields,
})),
remove: input.diff.remove,
},
})
.returning();
// Don't notify on self-PRs (author proposing a change to their own deck).
if (this.notify && deck.ownerUserId !== authorUserId) {
void this.notify.send({
channel: 'email',
userId: deck.ownerUserId,
subject: `Neuer Pull Request für „${deck.title}"`,
body: `Du hast einen neuen Pull Request bekommen: „${input.title}"\n\nÖffne ${this.deckUrl(deckSlug)}, um zu reviewen.`,
data: {
type: 'cards.pr.created',
deckSlug,
prId: pr.id,
url: this.deckUrl(deckSlug),
},
externalId: `cards.pr.created.${pr.id}`,
});
}
return pr;
}
private deckUrl(slug: string): string {
const base = process.env.CARDS_WEB_URL || 'https://cardecky.mana.how';
return `${base}/d/${slug}`;
}
async list(deckSlug: string, status?: 'open' | 'merged' | 'closed' | 'rejected') {
const deck = await this.db.query.publicDecks.findFirst({
where: eq(publicDecks.slug, deckSlug),
});
if (!deck) throw new NotFoundError('Deck not found');
const where = status
? and(eq(deckPullRequests.deckId, deck.id), eq(deckPullRequests.status, status))
: eq(deckPullRequests.deckId, deck.id);
return this.db
.select()
.from(deckPullRequests)
.where(where)
.orderBy(desc(deckPullRequests.createdAt));
}
async get(prId: string) {
const pr = await this.db.query.deckPullRequests.findFirst({
where: eq(deckPullRequests.id, prId),
});
if (!pr) throw new NotFoundError('Pull request not found');
return pr;
}
async close(actorUserId: string, prId: string): Promise<void> {
const pr = await this.get(prId);
const deck = await this.db.query.publicDecks.findFirst({
where: eq(publicDecks.id, pr.deckId),
});
if (!deck) throw new NotFoundError('Deck not found');
// Either the deck owner or the PR author can close.
if (pr.authorUserId !== actorUserId && deck.ownerUserId !== actorUserId) {
throw new ForbiddenError('Only PR author or deck owner can close');
}
if (pr.status !== 'open') throw new BadRequestError(`PR already ${pr.status}`);
await this.db
.update(deckPullRequests)
.set({ status: 'closed', resolvedAt: new Date() })
.where(eq(deckPullRequests.id, prId));
}
async reject(actorUserId: string, prId: string): Promise<void> {
const pr = await this.get(prId);
const deck = await this.db.query.publicDecks.findFirst({
where: eq(publicDecks.id, pr.deckId),
});
if (!deck) throw new NotFoundError('Deck not found');
if (deck.ownerUserId !== actorUserId) {
throw new ForbiddenError('Only the deck owner can reject');
}
if (pr.status !== 'open') throw new BadRequestError(`PR already ${pr.status}`);
await this.db
.update(deckPullRequests)
.set({ status: 'rejected', resolvedAt: new Date() })
.where(eq(deckPullRequests.id, prId));
if (this.notify && pr.authorUserId !== actorUserId) {
void this.notify.send({
channel: 'email',
userId: pr.authorUserId,
subject: `Pull Request „${pr.title}" abgelehnt`,
body: `Dein Pull Request für „${deck.title}" wurde abgelehnt. Siehe ${this.deckUrl(deck.slug)}.`,
data: { type: 'cards.pr.rejected', prId: pr.id, deckSlug: deck.slug },
externalId: `cards.pr.rejected.${pr.id}`,
});
}
}
/**
* Merge a PR. Builds a brand-new version's card list by applying
* the PR's diff to the deck's latest version, then writes the
* usual version + cards rows and bumps `latest_version_id`.
*
* The merge happens in a single transaction so a partial failure
* doesn't leave the deck pointing at an empty version.
*/
async merge(
actorUserId: string,
prId: string,
opts: { newSemver?: string; mergeNote?: string } = {}
) {
const pr = await this.get(prId);
if (pr.status !== 'open') throw new BadRequestError(`PR already ${pr.status}`);
const deck = await this.db.query.publicDecks.findFirst({
where: eq(publicDecks.id, pr.deckId),
});
if (!deck) throw new NotFoundError('Deck not found');
if (deck.ownerUserId !== actorUserId) {
throw new ForbiddenError('Only the deck owner can merge');
}
if (!deck.latestVersionId) {
throw new BadRequestError('Deck has no published version yet — publish first');
}
const latest = await this.db.query.publicDeckVersions.findFirst({
where: eq(publicDeckVersions.id, deck.latestVersionId),
});
if (!latest) throw new NotFoundError('Latest version row missing');
const newSemver = opts.newSemver ?? bumpMinor(latest.semver);
if (!SEMVER_RE.test(newSemver)) {
throw new BadRequestError(`Invalid semver: ${newSemver}`);
}
// Pull current cards as the base for the merge.
const currentCards = await this.db
.select()
.from(publicDeckCards)
.where(eq(publicDeckCards.versionId, latest.id))
.orderBy(publicDeckCards.ord);
const diff = pr.diff as {
add: { type: string; fields: Record<string, string> }[];
modify: { contentHash: string; fields: Record<string, string> }[];
remove: { contentHash: string }[];
};
const removedHashes = new Set(diff.remove.map((r) => r.contentHash));
const modifyByHash = new Map(diff.modify.map((m) => [m.contentHash, m.fields]));
const merged: { type: string; fields: Record<string, string>; ord: number }[] = [];
let nextOrd = 0;
for (const c of currentCards) {
if (removedHashes.has(c.contentHash)) continue;
const replaced = modifyByHash.get(c.contentHash);
merged.push({
type: c.type,
fields: replaced ?? (c.fields as Record<string, string>),
ord: nextOrd++,
});
}
for (const a of diff.add) {
merged.push({ type: a.type, fields: a.fields, ord: nextOrd++ });
}
if (merged.length === 0) {
throw new BadRequestError('Merge would result in an empty deck — refusing');
}
const versionContentHash = hashVersionCards(merged);
const result = await this.db.transaction(async (tx) => {
const [version] = await tx
.insert(publicDeckVersions)
.values({
deckId: deck.id,
semver: newSemver,
changelog:
opts.mergeNote ??
`Merged PR: ${pr.title} (+${diff.add.length} added, ~${diff.modify.length} modified, ${diff.remove.length} removed)`,
contentHash: versionContentHash,
cardCount: merged.length,
})
.returning();
await tx.insert(publicDeckCards).values(
merged.map((c) => ({
versionId: version.id,
type: c.type as
| 'basic'
| 'basic-reverse'
| 'cloze'
| 'type-in'
| 'image-occlusion'
| 'audio'
| 'multiple-choice',
fields: c.fields,
ord: c.ord,
contentHash: hashCard({ type: c.type, fields: c.fields }),
}))
);
await tx
.update(publicDecks)
.set({ latestVersionId: version.id })
.where(eq(publicDecks.id, deck.id));
await tx
.update(deckPullRequests)
.set({
status: 'merged',
mergedIntoVersionId: version.id,
resolvedAt: new Date(),
})
.where(eq(deckPullRequests.id, prId));
return { version };
});
if (this.notify && pr.authorUserId !== actorUserId) {
void this.notify.send({
channel: 'email',
userId: pr.authorUserId,
subject: `Pull Request „${pr.title}" gemerged`,
body: `Dein Pull Request für „${deck.title}" ist live in v${newSemver}. Danke für den Beitrag!`,
data: {
type: 'cards.pr.merged',
prId: pr.id,
deckSlug: deck.slug,
newSemver,
url: this.deckUrl(deck.slug),
},
externalId: `cards.pr.merged.${pr.id}`,
});
}
return { pullRequest: { ...pr, status: 'merged' as const }, version: result.version };
}
}

View file

@ -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<boolean> {
const row = await this.db.query.deckPurchases.findFirst({
where: and(eq(deckPurchases.buyerUserId, buyerUserId), eq(deckPurchases.deckId, deckId)),
});
return !!row && !row.refundedAt;
}
async listForBuyer(buyerUserId: string) {
const rows = await this.db
.select({
id: deckPurchases.id,
deckId: deckPurchases.deckId,
deckSlug: publicDecks.slug,
deckTitle: publicDecks.title,
priceCredits: deckPurchases.priceCredits,
purchasedAt: deckPurchases.purchasedAt,
refundedAt: deckPurchases.refundedAt,
versionId: deckPurchases.versionId,
versionSemver: publicDeckVersions.semver,
})
.from(deckPurchases)
.innerJoin(publicDecks, eq(deckPurchases.deckId, publicDecks.id))
.innerJoin(publicDeckVersions, eq(deckPurchases.versionId, publicDeckVersions.id))
.where(eq(deckPurchases.buyerUserId, buyerUserId))
.orderBy(desc(deckPurchases.purchasedAt));
return rows;
}
async listPayoutsForAuthor(authorUserId: string) {
const rows = await this.db
.select({
id: authorPayouts.id,
purchaseId: authorPayouts.sourcePurchaseId,
creditsGranted: authorPayouts.creditsGranted,
grantedAt: authorPayouts.grantedAt,
deckSlug: publicDecks.slug,
deckTitle: publicDecks.title,
priceCredits: deckPurchases.priceCredits,
})
.from(authorPayouts)
.innerJoin(deckPurchases, eq(authorPayouts.sourcePurchaseId, deckPurchases.id))
.innerJoin(publicDecks, eq(deckPurchases.deckId, publicDecks.id))
.where(eq(authorPayouts.authorUserId, authorUserId))
.orderBy(desc(authorPayouts.grantedAt));
return rows;
}
}

View file

@ -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<string, string>;
ord: number;
}
export interface DiffPayload {
from: string;
to: string;
added: VersionCardPayload[];
changed: { previous: { contentHash: string }; next: VersionCardPayload }[];
unchanged: { contentHash: string; ord: number }[];
removed: { contentHash: string }[];
}
export class SubscriptionService {
constructor(private readonly db: Database) {}
async subscribe(userId: string, deckSlug: string) {
const deck = await this.db.query.publicDecks.findFirst({
where: eq(publicDecks.slug, deckSlug),
});
if (!deck) throw new NotFoundError('Deck not found');
if (deck.isTakedown) throw new ForbiddenError('Deck under takedown');
if (!deck.latestVersionId) throw new ConflictError('Deck has no published version yet');
// Paid decks need a non-refunded purchase before the user can
// subscribe (= pull the cards). The author themselves can
// always subscribe to their own paid deck for testing.
if (deck.priceCredits > 0 && deck.ownerUserId !== userId) {
const purchase = await this.db.query.deckPurchases.findFirst({
where: and(eq(deckPurchases.buyerUserId, userId), eq(deckPurchases.deckId, deck.id)),
});
if (!purchase || purchase.refundedAt) {
throw new ForbiddenError('Paid deck — purchase required before subscribing');
}
}
await this.db
.insert(deckSubscriptions)
.values({
userId,
deckId: deck.id,
currentVersionId: deck.latestVersionId,
})
.onConflictDoUpdate({
target: [deckSubscriptions.userId, deckSubscriptions.deckId],
set: { currentVersionId: deck.latestVersionId },
});
return { deckSlug, latestVersionId: deck.latestVersionId };
}
async unsubscribe(userId: string, deckSlug: string) {
const deck = await this.db.query.publicDecks.findFirst({
where: eq(publicDecks.slug, deckSlug),
});
if (!deck) throw new NotFoundError('Deck not found');
await this.db
.delete(deckSubscriptions)
.where(and(eq(deckSubscriptions.userId, userId), eq(deckSubscriptions.deckId, deck.id)));
}
async listForUser(userId: string) {
const rows = await this.db
.select({
deckSlug: publicDecks.slug,
deckTitle: publicDecks.title,
deckDescription: publicDecks.description,
deckLatestVersionId: publicDecks.latestVersionId,
subscribedAt: deckSubscriptions.subscribedAt,
notifyUpdates: deckSubscriptions.notifyUpdates,
currentVersionId: deckSubscriptions.currentVersionId,
})
.from(deckSubscriptions)
.innerJoin(publicDecks, eq(publicDecks.id, deckSubscriptions.deckId))
.where(eq(deckSubscriptions.userId, userId))
.orderBy(deckSubscriptions.subscribedAt);
return rows.map((r) => ({
deckSlug: r.deckSlug,
deckTitle: r.deckTitle,
deckDescription: r.deckDescription,
subscribedAt: r.subscribedAt,
notifyUpdates: r.notifyUpdates,
currentVersionId: r.currentVersionId,
latestVersionId: r.deckLatestVersionId,
updateAvailable:
r.deckLatestVersionId !== null && r.currentVersionId !== r.deckLatestVersionId,
}));
}
async versionWithCards(deckSlug: string, semver: string): Promise<VersionPayload> {
const deck = await this.db.query.publicDecks.findFirst({
where: eq(publicDecks.slug, deckSlug),
});
if (!deck) throw new NotFoundError('Deck not found');
const version = await this.db.query.publicDeckVersions.findFirst({
where: and(eq(publicDeckVersions.deckId, deck.id), eq(publicDeckVersions.semver, semver)),
});
if (!version) throw new NotFoundError(`Version ${semver} not found`);
const cards = await this.db
.select()
.from(publicDeckCards)
.where(eq(publicDeckCards.versionId, version.id))
.orderBy(asc(publicDeckCards.ord));
return {
id: version.id,
semver: version.semver,
contentHash: version.contentHash,
publishedAt: version.publishedAt,
changelog: version.changelog,
cards: cards.map((c) => ({
contentHash: c.contentHash,
type: c.type,
fields: c.fields as Record<string, string>,
ord: c.ord,
})),
};
}
/** Smart-merge diff: tell the client what changed since `fromSemver`. */
async diffSince(deckSlug: string, fromSemver: string): Promise<DiffPayload> {
const deck = await this.db.query.publicDecks.findFirst({
where: eq(publicDecks.slug, deckSlug),
});
if (!deck) throw new NotFoundError('Deck not found');
if (!deck.latestVersionId) throw new NotFoundError('Deck has no published version');
const latestVersion = await this.db.query.publicDeckVersions.findFirst({
where: eq(publicDeckVersions.id, deck.latestVersionId),
});
if (!latestVersion) throw new NotFoundError('Latest version row missing');
const fromVersion = await this.db.query.publicDeckVersions.findFirst({
where: and(eq(publicDeckVersions.deckId, deck.id), eq(publicDeckVersions.semver, fromSemver)),
});
if (!fromVersion) throw new NotFoundError(`Version ${fromSemver} not found`);
// Empty diff if already on latest.
if (fromVersion.id === latestVersion.id) {
return {
from: fromSemver,
to: latestVersion.semver,
added: [],
changed: [],
unchanged: [],
removed: [],
};
}
const [fromCards, toCards] = await Promise.all([
this.db
.select({ contentHash: publicDeckCards.contentHash, ord: publicDeckCards.ord })
.from(publicDeckCards)
.where(eq(publicDeckCards.versionId, fromVersion.id)),
this.db
.select()
.from(publicDeckCards)
.where(eq(publicDeckCards.versionId, latestVersion.id))
.orderBy(asc(publicDeckCards.ord)),
]);
const fromHashes = new Set(fromCards.map((c) => c.contentHash));
const toHashes = new Set(toCards.map((c) => c.contentHash));
// Cards that are still here verbatim.
const unchanged: { contentHash: string; ord: number }[] = [];
// Brand-new cards (hash not in `from`).
const added: VersionCardPayload[] = [];
// Cards in `from` that vanished completely.
const removed: { contentHash: string }[] = fromCards
.filter((c) => !toHashes.has(c.contentHash))
.map((c) => ({ contentHash: c.contentHash }));
// `changed` is hard to detect without a stable card-id across
// versions. We approximate by treating ord-position pairs that
// neither match nor are in the unchanged set: an "added" at the
// same ord as a "removed" → changed. Phase ε's pull-request
// pipeline gives us a real card-lineage; until then this
// heuristic is good enough.
const changed: { previous: { contentHash: string }; next: VersionCardPayload }[] = [];
const removedByOrd = new Map<number, string>();
for (const c of fromCards) {
if (!toHashes.has(c.contentHash)) removedByOrd.set(c.ord, c.contentHash);
}
for (const c of toCards) {
if (fromHashes.has(c.contentHash)) {
unchanged.push({ contentHash: c.contentHash, ord: c.ord });
} else if (removedByOrd.has(c.ord)) {
const prevHash = removedByOrd.get(c.ord)!;
removedByOrd.delete(c.ord);
changed.push({
previous: { contentHash: prevHash },
next: {
contentHash: c.contentHash,
type: c.type,
fields: c.fields as Record<string, string>,
ord: c.ord,
},
});
} else {
added.push({
contentHash: c.contentHash,
type: c.type,
fields: c.fields as Record<string, string>,
ord: c.ord,
});
}
}
// Anything left in removedByOrd is a real removal (not paired up
// with a `changed`).
const trueRemoved = removed.filter((r) => [...removedByOrd.values()].includes(r.contentHash));
return {
from: fromSemver,
to: latestVersion.semver,
added,
changed,
unchanged,
removed: trueRemoved,
};
}
}

View file

@ -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"]
}