mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 16:41:08 +02:00
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:
parent
9cd8717494
commit
bc158cb0bc
52 changed files with 0 additions and 8012 deletions
5
services/cards-server/.gitignore
vendored
5
services/cards-server/.gitignore
vendored
|
|
@ -1,5 +0,0 @@
|
|||
node_modules
|
||||
.env
|
||||
.env.local
|
||||
.DS_Store
|
||||
drizzle/meta/_journal.json.bak
|
||||
|
|
@ -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".
|
||||
|
|
@ -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"]
|
||||
|
|
@ -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.
|
||||
|
|
@ -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'],
|
||||
});
|
||||
|
|
@ -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");
|
||||
|
|
@ -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
|
|
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -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>;
|
||||
|
|
@ -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');
|
||||
|
|
@ -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),
|
||||
})
|
||||
);
|
||||
|
|
@ -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),
|
||||
})
|
||||
);
|
||||
|
|
@ -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),
|
||||
})
|
||||
);
|
||||
|
|
@ -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),
|
||||
})
|
||||
);
|
||||
|
|
@ -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),
|
||||
})
|
||||
);
|
||||
|
|
@ -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';
|
||||
|
|
@ -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),
|
||||
})
|
||||
);
|
||||
|
|
@ -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),
|
||||
})
|
||||
);
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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),
|
||||
};
|
||||
}
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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 })))
|
||||
);
|
||||
}
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
@ -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();
|
||||
};
|
||||
}
|
||||
|
|
@ -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();
|
||||
};
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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(),
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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 };
|
||||
}
|
||||
}
|
||||
|
|
@ -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 };
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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"]
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue