From ff00c7d96193783fe5c096b7b396584e694f5f2c Mon Sep 17 00:00:00 2001 From: Till JS Date: Thu, 14 May 2026 02:04:54 +0200 Subject: [PATCH] feat(marketplace): Deck-Report + Author-Block + me/decks-Endpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cardecky-Marketplace bekommt die App-Store-Guideline-5.1.1(v)- Pflicht-Komponenten für User-Generated-Content: User können einzelne Decks melden und Authors blockieren. Plus `GET /me/decks` für den Native-Re-Publish-Flow. Schema (Migration 0003) - Neue Tabelle `marketplace.author_blocks (blocker_user_id, blocked_user_id, created_at)` mit Unique-Index auf dem Tupel - `deckReports` lag schon im Schema, jetzt erstmals durch Routes erreichbar Routes - POST /api/v1/marketplace/decks/:slug/report — auth, 10/min Rate- Limit, Kategorie-Enum (spam, copyright, nsfw, misinformation, hate, other), optional `body` ≤ 1000 Zeichen. Idempotent pro (deck, reporter, category): doppeltes Melden liefert `already_reported: true` ohne Fehler. Owner darf eigenes Deck nicht melden. - POST /api/v1/marketplace/authors/:slug/block — idempotent (onConflictDoNothing). Self-Block geht 422. - DELETE /api/v1/marketplace/authors/:slug/block - GET /api/v1/marketplace/me/blocks — eigene Block-Liste mit display_name + blocked_at - GET /api/v1/marketplace/me/decks — eigene Marketplace-Decks mit latest_version (semver, card_count, published_at). Native nutzt das für die „Neue Version"-Auswahl im Publish-Flow Listing-Filter - explore.ts: `browseImpl` nimmt `signedInUserId?` und filtert blockierte Author-Decks per `NOT EXISTS`. Wirkt auf /explore + /decks (Browse mit Filtern) - decks.ts: `GET /:slug` returnt 404 wenn der Viewer den Author blockiert hat — bewusst 404 statt 403, UGC-Block soll ohne Hinweis auf den Block wirken Mount: zwei neue Router auf /api/v1/marketplace (moderation) und /api/v1/marketplace/me. 104/104 Vitest-Tests grün. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../migrations/0003_serious_peter_parker.sql | 9 + .../src/db/migrations/meta/0003_snapshot.json | 2972 +++++++++++++++++ apps/api/src/db/migrations/meta/_journal.json | 7 + apps/api/src/db/schema/index.ts | 1 + .../src/db/schema/marketplace/engagement.ts | 23 + apps/api/src/index.ts | 4 + apps/api/src/routes/marketplace/decks.ts | 19 + apps/api/src/routes/marketplace/explore.ts | 19 +- apps/api/src/routes/marketplace/me.ts | 104 + apps/api/src/routes/marketplace/moderation.ts | 137 + 10 files changed, 3291 insertions(+), 4 deletions(-) create mode 100644 apps/api/src/db/migrations/0003_serious_peter_parker.sql create mode 100644 apps/api/src/db/migrations/meta/0003_snapshot.json create mode 100644 apps/api/src/routes/marketplace/me.ts create mode 100644 apps/api/src/routes/marketplace/moderation.ts diff --git a/apps/api/src/db/migrations/0003_serious_peter_parker.sql b/apps/api/src/db/migrations/0003_serious_peter_parker.sql new file mode 100644 index 0000000..141e11d --- /dev/null +++ b/apps/api/src/db/migrations/0003_serious_peter_parker.sql @@ -0,0 +1,9 @@ +CREATE TABLE "marketplace"."author_blocks" ( + "blocker_user_id" text NOT NULL, + "blocked_user_id" text NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "cards"."reviews" ADD COLUMN "prev_snapshot" jsonb DEFAULT 'null'::jsonb;--> statement-breakpoint +CREATE UNIQUE INDEX "author_blocks_pk" ON "marketplace"."author_blocks" USING btree ("blocker_user_id","blocked_user_id");--> statement-breakpoint +CREATE INDEX "author_blocks_blocker_idx" ON "marketplace"."author_blocks" USING btree ("blocker_user_id"); \ No newline at end of file diff --git a/apps/api/src/db/migrations/meta/0003_snapshot.json b/apps/api/src/db/migrations/meta/0003_snapshot.json new file mode 100644 index 0000000..614d46a --- /dev/null +++ b/apps/api/src/db/migrations/meta/0003_snapshot.json @@ -0,0 +1,2972 @@ +{ + "id": "e98364f5-3943-47a9-9484-96defc94b990", + "prevId": "132d3ee9-0062-4600-a7e4-e6fad6fcdb75", + "version": "7", + "dialect": "postgresql", + "tables": { + "cards.card_tags": { + "name": "card_tags", + "schema": "cards", + "columns": { + "card_id": { + "name": "card_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tag_id": { + "name": "tag_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "card_tags_card_id_cards_id_fk": { + "name": "card_tags_card_id_cards_id_fk", + "tableFrom": "card_tags", + "tableTo": "cards", + "schemaTo": "cards", + "columnsFrom": [ + "card_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "card_tags_card_id_tag_id_pk": { + "name": "card_tags_card_id_tag_id_pk", + "columns": [ + "card_id", + "tag_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "cards.cards": { + "name": "cards", + "schema": "cards", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "deck_id": { + "name": "deck_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "fields": { + "name": "fields", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "media_refs": { + "name": "media_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "cards_deck_idx": { + "name": "cards_deck_idx", + "columns": [ + { + "expression": "deck_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "cards_user_idx": { + "name": "cards_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "cards_deck_id_decks_id_fk": { + "name": "cards_deck_id_decks_id_fk", + "tableFrom": "cards", + "tableTo": "decks", + "schemaTo": "cards", + "columnsFrom": [ + "deck_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "cards.decks": { + "name": "decks", + "schema": "cards", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "visibility": { + "name": "visibility", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'private'" + }, + "fsrs_settings": { + "name": "fsrs_settings", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "forked_from_marketplace_deck_id": { + "name": "forked_from_marketplace_deck_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "forked_from_marketplace_version_id": { + "name": "forked_from_marketplace_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "decks_user_idx": { + "name": "decks_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "cards.import_jobs": { + "name": "import_jobs", + "schema": "cards", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "meta": { + "name": "meta", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "imports_user_idx": { + "name": "imports_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "imports_state_idx": { + "name": "imports_state_idx", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "marketplace.ai_moderation_log": { + "name": "ai_moderation_log", + "schema": "marketplace", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "version_id": { + "name": "version_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "verdict": { + "name": "verdict", + "type": "ai_mod_verdict", + "typeSchema": "marketplace", + "primaryKey": false, + "notNull": true + }, + "categories": { + "name": "categories", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rationale": { + "name": "rationale", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "human_reviewed": { + "name": "human_reviewed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "human_overrode": { + "name": "human_overrode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "ai_moderation_log_version_idx": { + "name": "ai_moderation_log_version_idx", + "columns": [ + { + "expression": "version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "ai_moderation_log_verdict_idx": { + "name": "ai_moderation_log_verdict_idx", + "columns": [ + { + "expression": "verdict", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ai_moderation_log_version_id_deck_versions_id_fk": { + "name": "ai_moderation_log_version_id_deck_versions_id_fk", + "tableFrom": "ai_moderation_log", + "tableTo": "deck_versions", + "schemaTo": "marketplace", + "columnsFrom": [ + "version_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "marketplace.author_follows": { + "name": "author_follows", + "schema": "marketplace", + "columns": { + "follower_user_id": { + "name": "follower_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "author_user_id": { + "name": "author_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "since": { + "name": "since", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "author_follows_pk": { + "name": "author_follows_pk", + "columns": [ + { + "expression": "follower_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "author_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "author_follows_author_idx": { + "name": "author_follows_author_idx", + "columns": [ + { + "expression": "author_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "author_follows_follower_idx": { + "name": "author_follows_follower_idx", + "columns": [ + { + "expression": "follower_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "author_follows_author_user_id_authors_user_id_fk": { + "name": "author_follows_author_user_id_authors_user_id_fk", + "tableFrom": "author_follows", + "tableTo": "authors", + "schemaTo": "marketplace", + "columnsFrom": [ + "author_user_id" + ], + "columnsTo": [ + "user_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "marketplace.author_payouts": { + "name": "author_payouts", + "schema": "marketplace", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "author_user_id": { + "name": "author_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_purchase_id": { + "name": "source_purchase_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "credits_granted": { + "name": "credits_granted", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "credits_grant_id": { + "name": "credits_grant_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "granted_at": { + "name": "granted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "author_payouts_author_idx": { + "name": "author_payouts_author_idx", + "columns": [ + { + "expression": "author_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "author_payouts_purchase_idx": { + "name": "author_payouts_purchase_idx", + "columns": [ + { + "expression": "source_purchase_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "author_payouts_author_user_id_authors_user_id_fk": { + "name": "author_payouts_author_user_id_authors_user_id_fk", + "tableFrom": "author_payouts", + "tableTo": "authors", + "schemaTo": "marketplace", + "columnsFrom": [ + "author_user_id" + ], + "columnsTo": [ + "user_id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "author_payouts_source_purchase_id_deck_purchases_id_fk": { + "name": "author_payouts_source_purchase_id_deck_purchases_id_fk", + "tableFrom": "author_payouts", + "tableTo": "deck_purchases", + "schemaTo": "marketplace", + "columnsFrom": [ + "source_purchase_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "marketplace.authors": { + "name": "authors", + "schema": "marketplace", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bio": { + "name": "bio", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "pseudonym": { + "name": "pseudonym", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "verified_mana": { + "name": "verified_mana", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "verified_community": { + "name": "verified_community", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "banned_at": { + "name": "banned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "banned_reason": { + "name": "banned_reason", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "authors_slug_idx": { + "name": "authors_slug_idx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "authors_verified_idx": { + "name": "authors_verified_idx", + "columns": [ + { + "expression": "verified_mana", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "verified_community", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "marketplace.card_discussions": { + "name": "card_discussions", + "schema": "marketplace", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "card_content_hash": { + "name": "card_content_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deck_id": { + "name": "deck_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "author_user_id": { + "name": "author_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hidden": { + "name": "hidden", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "card_discussions_hash_idx": { + "name": "card_discussions_hash_idx", + "columns": [ + { + "expression": "card_content_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "card_discussions_deck_idx": { + "name": "card_discussions_deck_idx", + "columns": [ + { + "expression": "deck_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "card_discussions_parent_idx": { + "name": "card_discussions_parent_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "card_discussions_deck_id_decks_id_fk": { + "name": "card_discussions_deck_id_decks_id_fk", + "tableFrom": "card_discussions", + "tableTo": "decks", + "schemaTo": "marketplace", + "columnsFrom": [ + "deck_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "marketplace.deck_forks": { + "name": "deck_forks", + "schema": "marketplace", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_deck_id": { + "name": "source_deck_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "source_version_id": { + "name": "source_version_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "forked_at": { + "name": "forked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "deck_forks_pk": { + "name": "deck_forks_pk", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_deck_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "deck_forks_source_idx": { + "name": "deck_forks_source_idx", + "columns": [ + { + "expression": "source_deck_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "deck_forks_source_deck_id_decks_id_fk": { + "name": "deck_forks_source_deck_id_decks_id_fk", + "tableFrom": "deck_forks", + "tableTo": "decks", + "schemaTo": "marketplace", + "columnsFrom": [ + "source_deck_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "deck_forks_source_version_id_deck_versions_id_fk": { + "name": "deck_forks_source_version_id_deck_versions_id_fk", + "tableFrom": "deck_forks", + "tableTo": "deck_versions", + "schemaTo": "marketplace", + "columnsFrom": [ + "source_version_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "marketplace.deck_pull_requests": { + "name": "deck_pull_requests", + "schema": "marketplace", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "deck_id": { + "name": "deck_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "author_user_id": { + "name": "author_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "pr_status", + "typeSchema": "marketplace", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "diff": { + "name": "diff", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "merged_into_version_id": { + "name": "merged_into_version_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "resolved_at": { + "name": "resolved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "deck_pull_requests_deck_idx": { + "name": "deck_pull_requests_deck_idx", + "columns": [ + { + "expression": "deck_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "deck_pull_requests_status_idx": { + "name": "deck_pull_requests_status_idx", + "columns": [ + { + "expression": "deck_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "deck_pull_requests_author_idx": { + "name": "deck_pull_requests_author_idx", + "columns": [ + { + "expression": "author_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "deck_pull_requests_deck_id_decks_id_fk": { + "name": "deck_pull_requests_deck_id_decks_id_fk", + "tableFrom": "deck_pull_requests", + "tableTo": "decks", + "schemaTo": "marketplace", + "columnsFrom": [ + "deck_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "deck_pull_requests_merged_into_version_id_deck_versions_id_fk": { + "name": "deck_pull_requests_merged_into_version_id_deck_versions_id_fk", + "tableFrom": "deck_pull_requests", + "tableTo": "deck_versions", + "schemaTo": "marketplace", + "columnsFrom": [ + "merged_into_version_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "marketplace.deck_purchases": { + "name": "deck_purchases", + "schema": "marketplace", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "buyer_user_id": { + "name": "buyer_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deck_id": { + "name": "deck_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "version_id": { + "name": "version_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "price_credits": { + "name": "price_credits", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "author_share": { + "name": "author_share", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "mana_share": { + "name": "mana_share", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "credits_transaction": { + "name": "credits_transaction", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "purchased_at": { + "name": "purchased_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "refunded_at": { + "name": "refunded_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "deck_purchases_buyer_deck_idx": { + "name": "deck_purchases_buyer_deck_idx", + "columns": [ + { + "expression": "buyer_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deck_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "deck_purchases_buyer_idx": { + "name": "deck_purchases_buyer_idx", + "columns": [ + { + "expression": "buyer_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "deck_purchases_deck_idx": { + "name": "deck_purchases_deck_idx", + "columns": [ + { + "expression": "deck_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "deck_purchases_deck_id_decks_id_fk": { + "name": "deck_purchases_deck_id_decks_id_fk", + "tableFrom": "deck_purchases", + "tableTo": "decks", + "schemaTo": "marketplace", + "columnsFrom": [ + "deck_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "deck_purchases_version_id_deck_versions_id_fk": { + "name": "deck_purchases_version_id_deck_versions_id_fk", + "tableFrom": "deck_purchases", + "tableTo": "deck_versions", + "schemaTo": "marketplace", + "columnsFrom": [ + "version_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "marketplace.deck_reports": { + "name": "deck_reports", + "schema": "marketplace", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "deck_id": { + "name": "deck_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "version_id": { + "name": "version_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "card_content_hash": { + "name": "card_content_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reporter_user_id": { + "name": "reporter_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "category": { + "name": "category", + "type": "report_category", + "typeSchema": "marketplace", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "report_status", + "typeSchema": "marketplace", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "resolved_by": { + "name": "resolved_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resolved_at": { + "name": "resolved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "resolution_notes": { + "name": "resolution_notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "deck_reports_deck_idx": { + "name": "deck_reports_deck_idx", + "columns": [ + { + "expression": "deck_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "deck_reports_status_idx": { + "name": "deck_reports_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "deck_reports_deck_id_decks_id_fk": { + "name": "deck_reports_deck_id_decks_id_fk", + "tableFrom": "deck_reports", + "tableTo": "decks", + "schemaTo": "marketplace", + "columnsFrom": [ + "deck_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "deck_reports_version_id_deck_versions_id_fk": { + "name": "deck_reports_version_id_deck_versions_id_fk", + "tableFrom": "deck_reports", + "tableTo": "deck_versions", + "schemaTo": "marketplace", + "columnsFrom": [ + "version_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "marketplace.deck_stars": { + "name": "deck_stars", + "schema": "marketplace", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deck_id": { + "name": "deck_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "starred_at": { + "name": "starred_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "deck_stars_pk": { + "name": "deck_stars_pk", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deck_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "deck_stars_deck_idx": { + "name": "deck_stars_deck_idx", + "columns": [ + { + "expression": "deck_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "deck_stars_deck_id_decks_id_fk": { + "name": "deck_stars_deck_id_decks_id_fk", + "tableFrom": "deck_stars", + "tableTo": "decks", + "schemaTo": "marketplace", + "columnsFrom": [ + "deck_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "marketplace.deck_subscriptions": { + "name": "deck_subscriptions", + "schema": "marketplace", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deck_id": { + "name": "deck_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "current_version_id": { + "name": "current_version_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "subscribed_at": { + "name": "subscribed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "notify_updates": { + "name": "notify_updates", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + } + }, + "indexes": { + "deck_subscriptions_pk": { + "name": "deck_subscriptions_pk", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deck_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "deck_subscriptions_deck_idx": { + "name": "deck_subscriptions_deck_idx", + "columns": [ + { + "expression": "deck_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "deck_subscriptions_user_idx": { + "name": "deck_subscriptions_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "deck_subscriptions_deck_id_decks_id_fk": { + "name": "deck_subscriptions_deck_id_decks_id_fk", + "tableFrom": "deck_subscriptions", + "tableTo": "decks", + "schemaTo": "marketplace", + "columnsFrom": [ + "deck_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "deck_subscriptions_current_version_id_deck_versions_id_fk": { + "name": "deck_subscriptions_current_version_id_deck_versions_id_fk", + "tableFrom": "deck_subscriptions", + "tableTo": "deck_versions", + "schemaTo": "marketplace", + "columnsFrom": [ + "current_version_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "marketplace.deck_tags": { + "name": "deck_tags", + "schema": "marketplace", + "columns": { + "deck_id": { + "name": "deck_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "tag_id": { + "name": "tag_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "deck_tags_pk": { + "name": "deck_tags_pk", + "columns": [ + { + "expression": "deck_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tag_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "deck_tags_tag_idx": { + "name": "deck_tags_tag_idx", + "columns": [ + { + "expression": "tag_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "deck_tags_deck_id_decks_id_fk": { + "name": "deck_tags_deck_id_decks_id_fk", + "tableFrom": "deck_tags", + "tableTo": "decks", + "schemaTo": "marketplace", + "columnsFrom": [ + "deck_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "deck_tags_tag_id_tag_definitions_id_fk": { + "name": "deck_tags_tag_id_tag_definitions_id_fk", + "tableFrom": "deck_tags", + "tableTo": "tag_definitions", + "schemaTo": "marketplace", + "columnsFrom": [ + "tag_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "cards.media_files": { + "name": "media_files", + "schema": "cards", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "object_key": { + "name": "object_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "original_filename": { + "name": "original_filename", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "size_bytes": { + "name": "size_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "media_files_user_idx": { + "name": "media_files_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "cards.media_refs": { + "name": "media_refs", + "schema": "cards", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "card_id": { + "name": "card_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mana_media_object_id": { + "name": "mana_media_object_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ord": { + "name": "ord", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "media_card_idx": { + "name": "media_card_idx", + "columns": [ + { + "expression": "card_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "media_refs_card_id_cards_id_fk": { + "name": "media_refs_card_id_cards_id_fk", + "tableFrom": "media_refs", + "tableTo": "cards", + "schemaTo": "cards", + "columnsFrom": [ + "card_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "marketplace.deck_cards": { + "name": "deck_cards", + "schema": "marketplace", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "version_id": { + "name": "version_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "card_type", + "typeSchema": "marketplace", + "primaryKey": false, + "notNull": true + }, + "fields": { + "name": "fields", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "ord": { + "name": "ord", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "deck_cards_version_ord_idx": { + "name": "deck_cards_version_ord_idx", + "columns": [ + { + "expression": "version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "ord", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "deck_cards_hash_idx": { + "name": "deck_cards_hash_idx", + "columns": [ + { + "expression": "content_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "deck_cards_version_id_deck_versions_id_fk": { + "name": "deck_cards_version_id_deck_versions_id_fk", + "tableFrom": "deck_cards", + "tableTo": "deck_versions", + "schemaTo": "marketplace", + "columnsFrom": [ + "version_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "marketplace.deck_versions": { + "name": "deck_versions", + "schema": "marketplace", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "deck_id": { + "name": "deck_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "semver": { + "name": "semver", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "changelog": { + "name": "changelog", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "card_count": { + "name": "card_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deprecated_at": { + "name": "deprecated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "deck_versions_deck_semver_idx": { + "name": "deck_versions_deck_semver_idx", + "columns": [ + { + "expression": "deck_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "semver", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "deck_versions_deck_idx": { + "name": "deck_versions_deck_idx", + "columns": [ + { + "expression": "deck_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "deck_versions_hash_idx": { + "name": "deck_versions_hash_idx", + "columns": [ + { + "expression": "content_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "deck_versions_deck_id_decks_id_fk": { + "name": "deck_versions_deck_id_decks_id_fk", + "tableFrom": "deck_versions", + "tableTo": "decks", + "schemaTo": "marketplace", + "columnsFrom": [ + "deck_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "marketplace.decks": { + "name": "decks", + "schema": "marketplace", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "language": { + "name": "language", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "license": { + "name": "license", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'Cardecky-Personal-Use-1.0'" + }, + "price_credits": { + "name": "price_credits", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "latest_version_id": { + "name": "latest_version_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "is_featured": { + "name": "is_featured", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_takedown": { + "name": "is_takedown", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "takedown_at": { + "name": "takedown_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "takedown_reason": { + "name": "takedown_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "decks_slug_idx": { + "name": "decks_slug_idx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "decks_owner_idx": { + "name": "decks_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "decks_featured_idx": { + "name": "decks_featured_idx", + "columns": [ + { + "expression": "is_featured", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "decks_owner_user_id_authors_user_id_fk": { + "name": "decks_owner_user_id_authors_user_id_fk", + "tableFrom": "decks", + "tableTo": "authors", + "schemaTo": "marketplace", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "user_id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "decks_price_requires_license": { + "name": "decks_price_requires_license", + "value": "price_credits = 0 OR license = 'Cardecky-Pro-Only-1.0'" + } + }, + "isRLSEnabled": false + }, + "cards.reviews": { + "name": "reviews", + "schema": "cards", + "columns": { + "card_id": { + "name": "card_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sub_index": { + "name": "sub_index", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "due": { + "name": "due", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "stability": { + "name": "stability", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "difficulty": { + "name": "difficulty", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "elapsed_days": { + "name": "elapsed_days", + "type": "real", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "scheduled_days": { + "name": "scheduled_days", + "type": "real", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "learning_steps": { + "name": "learning_steps", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "reps": { + "name": "reps", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "lapses": { + "name": "lapses", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'new'" + }, + "last_review": { + "name": "last_review", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "prev_snapshot": { + "name": "prev_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'null'::jsonb" + } + }, + "indexes": { + "reviews_user_due_idx": { + "name": "reviews_user_due_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "due", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "reviews_card_id_cards_id_fk": { + "name": "reviews_card_id_cards_id_fk", + "tableFrom": "reviews", + "tableTo": "cards", + "schemaTo": "cards", + "columnsFrom": [ + "card_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "reviews_card_id_sub_index_pk": { + "name": "reviews_card_id_sub_index_pk", + "columns": [ + "card_id", + "sub_index" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "cards.study_sessions": { + "name": "study_sessions", + "schema": "cards", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deck_id": { + "name": "deck_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "cards_reviewed": { + "name": "cards_reviewed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cards_correct": { + "name": "cards_correct", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": { + "sessions_user_started_idx": { + "name": "sessions_user_started_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "study_sessions_deck_id_decks_id_fk": { + "name": "study_sessions_deck_id_decks_id_fk", + "tableFrom": "study_sessions", + "tableTo": "decks", + "schemaTo": "cards", + "columnsFrom": [ + "deck_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "marketplace.tag_definitions": { + "name": "tag_definitions", + "schema": "marketplace", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "curated": { + "name": "curated", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "tag_definitions_slug_idx": { + "name": "tag_definitions_slug_idx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tag_definitions_parent_idx": { + "name": "tag_definitions_parent_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "cards.tags": { + "name": "tags", + "schema": "cards", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "deck_id": { + "name": "deck_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "tags_deck_idx": { + "name": "tags_deck_idx", + "columns": [ + { + "expression": "deck_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tags_deck_name_uniq": { + "name": "tags_deck_name_uniq", + "columns": [ + { + "expression": "deck_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tags_deck_id_decks_id_fk": { + "name": "tags_deck_id_decks_id_fk", + "tableFrom": "tags", + "tableTo": "decks", + "schemaTo": "cards", + "columnsFrom": [ + "deck_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "marketplace.author_blocks": { + "name": "author_blocks", + "schema": "marketplace", + "columns": { + "blocker_user_id": { + "name": "blocker_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "blocked_user_id": { + "name": "blocked_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "author_blocks_pk": { + "name": "author_blocks_pk", + "columns": [ + { + "expression": "blocker_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "blocked_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "author_blocks_blocker_idx": { + "name": "author_blocks_blocker_idx", + "columns": [ + { + "expression": "blocker_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "marketplace.ai_mod_verdict": { + "name": "ai_mod_verdict", + "schema": "marketplace", + "values": [ + "pass", + "flag", + "block" + ] + }, + "marketplace.card_type": { + "name": "card_type", + "schema": "marketplace", + "values": [ + "basic", + "basic-reverse", + "cloze", + "type-in", + "image-occlusion", + "audio", + "multiple-choice" + ] + }, + "marketplace.pr_status": { + "name": "pr_status", + "schema": "marketplace", + "values": [ + "open", + "merged", + "closed", + "rejected" + ] + }, + "marketplace.report_category": { + "name": "report_category", + "schema": "marketplace", + "values": [ + "spam", + "copyright", + "nsfw", + "misinformation", + "hate", + "other" + ] + }, + "marketplace.report_status": { + "name": "report_status", + "schema": "marketplace", + "values": [ + "open", + "dismissed", + "actioned" + ] + } + }, + "schemas": { + "cards": "cards", + "marketplace": "marketplace" + }, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/apps/api/src/db/migrations/meta/_journal.json b/apps/api/src/db/migrations/meta/_journal.json index c0133cf..5931801 100644 --- a/apps/api/src/db/migrations/meta/_journal.json +++ b/apps/api/src/db/migrations/meta/_journal.json @@ -22,6 +22,13 @@ "when": 1779075600000, "tag": "0002_decks_archived_at", "breakpoints": true + }, + { + "idx": 3, + "version": "7", + "when": 1778712867859, + "tag": "0003_serious_peter_parker", + "breakpoints": true } ] } \ No newline at end of file diff --git a/apps/api/src/db/schema/index.ts b/apps/api/src/db/schema/index.ts index e4e8b4c..356ad05 100644 --- a/apps/api/src/db/schema/index.ts +++ b/apps/api/src/db/schema/index.ts @@ -50,6 +50,7 @@ export { deckStars, deckSubscriptions, deckForks, + authorBlocks, deckPullRequests, cardDiscussions, pullRequestStatusEnum, diff --git a/apps/api/src/db/schema/marketplace/engagement.ts b/apps/api/src/db/schema/marketplace/engagement.ts index 2108b18..b9bd82c 100644 --- a/apps/api/src/db/schema/marketplace/engagement.ts +++ b/apps/api/src/db/schema/marketplace/engagement.ts @@ -65,3 +65,26 @@ export const deckForks = marketplaceSchema.table( sourceIdx: index('deck_forks_source_idx').on(t.sourceDeckId), }) ); + +/** + * User-zu-User-Block. Wenn `blocker` einen `blocked` blockt, soll der + * `blocker` in keiner Marketplace-Listing- oder Detail-Antwort mehr + * Decks dieses Authors sehen. Pflicht-Komponente für App-Store- + * Guideline 5.1.1(v) (UGC-Block-Mechanismus). + * + * Keyed auf User-IDs (text, plain auth.users-Ref) statt auf + * `authors.userId`, damit auch nicht-author User (z.B. ein Reader, + * der zukünftig Author werden könnte) blockierbar bleiben. + */ +export const authorBlocks = marketplaceSchema.table( + 'author_blocks', + { + blockerUserId: text('blocker_user_id').notNull(), + blockedUserId: text('blocked_user_id').notNull(), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), + }, + (t) => ({ + pk: uniqueIndex('author_blocks_pk').on(t.blockerUserId, t.blockedUserId), + blockerIdx: index('author_blocks_blocker_idx').on(t.blockerUserId), + }) +); diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index ab43be4..5434542 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -23,6 +23,8 @@ import { subscriptionsRouter as marketplaceSubscriptionsRouter } from './routes/ import { forkRouter as marketplaceForkRouter } from './routes/marketplace/fork.ts'; import { pullRequestsRouter as marketplacePullRequestsRouter } from './routes/marketplace/pull-requests.ts'; import { discussionsRouter as marketplaceDiscussionsRouter } from './routes/marketplace/discussions.ts'; +import { moderationRouter as marketplaceModerationRouter } from './routes/marketplace/moderation.ts'; +import { marketplaceMeRouter } from './routes/marketplace/me.ts'; const app = new Hono(); @@ -78,6 +80,8 @@ app.route('/api/v1/marketplace', marketplaceSubscriptionsRouter()); app.route('/api/v1/marketplace', marketplaceForkRouter()); app.route('/api/v1/marketplace', marketplacePullRequestsRouter()); app.route('/api/v1/marketplace', marketplaceDiscussionsRouter()); +app.route('/api/v1/marketplace', marketplaceModerationRouter()); +app.route('/api/v1/marketplace/me', marketplaceMeRouter()); app.route('/api/v1/marketplace/authors', marketplaceAuthorsRouter()); app.route('/api/v1/marketplace/decks', marketplaceDecksRouter()); diff --git a/apps/api/src/routes/marketplace/decks.ts b/apps/api/src/routes/marketplace/decks.ts index 98a64b5..585cb4d 100644 --- a/apps/api/src/routes/marketplace/decks.ts +++ b/apps/api/src/routes/marketplace/decks.ts @@ -7,6 +7,7 @@ import { cardContentHash } from '@cards/domain'; import { getDb, type CardsDb } from '../../db/connection.ts'; import { aiModerationLog, + authorBlocks, authors, publicDeckCards, publicDeckVersions, @@ -118,6 +119,24 @@ export function marketplaceDecksRouter( const [deck] = await db.select().from(publicDecks).where(eq(publicDecks.slug, slug)).limit(1); if (!deck) return c.json({ error: 'not_found' }, 404); + // Block-Filter: wenn der angemeldete User den Author blockiert + // hat, behandeln wir das Deck wie nicht-existent. Bewusst 404 + // statt 403 — UGC-Block soll ohne Hinweis auf den Block wirken. + const viewerId = c.get('userId') as string | undefined; + if (viewerId && viewerId !== deck.ownerUserId) { + const [block] = await db + .select({ blockedUserId: authorBlocks.blockedUserId }) + .from(authorBlocks) + .where( + and( + eq(authorBlocks.blockerUserId, viewerId), + eq(authorBlocks.blockedUserId, deck.ownerUserId), + ), + ) + .limit(1); + if (block) return c.json({ error: 'not_found' }, 404); + } + const [latestVersion, ownerRow] = await Promise.all([ deck.latestVersionId ? db diff --git a/apps/api/src/routes/marketplace/explore.ts b/apps/api/src/routes/marketplace/explore.ts index ba759c7..b392009 100644 --- a/apps/api/src/routes/marketplace/explore.ts +++ b/apps/api/src/routes/marketplace/explore.ts @@ -72,7 +72,8 @@ interface DeckListEntry { async function browseImpl( db: CardsDb, - filter: z.infer + filter: z.infer, + signedInUserId?: string, ): Promise<{ items: DeckListEntry[]; total: number }> { const limit = filter.limit ?? 20; const offset = filter.offset ?? 0; @@ -98,6 +99,14 @@ async function browseImpl( sql`EXISTS (SELECT 1 FROM marketplace.deck_tags dt JOIN marketplace.tag_definitions td ON td.id = dt.tag_id WHERE dt.deck_id = ${publicDecks.id} AND td.slug = ${filter.tag})` ); } + // Block-Filter: wenn der anfragende User Authors blockiert hat, + // werden deren Decks aus dem Listing geworfen. Reine App-Store- + // Guideline-5.1.1(v)-Compliance — UGC-Block muss in Listings wirken. + if (signedInUserId) { + conditions.push( + sql`NOT EXISTS (SELECT 1 FROM marketplace.author_blocks ab WHERE ab.blocker_user_id = ${signedInUserId} AND ab.blocked_user_id = ${publicDecks.ownerUserId})` + ); + } const starCount = sql`(SELECT count(*)::int FROM marketplace.deck_stars s WHERE s.deck_id = ${publicDecks.id})`; const subscriberCount = sql`(SELECT count(*)::int FROM marketplace.deck_subscriptions s WHERE s.deck_id = ${publicDecks.id})`; @@ -180,11 +189,12 @@ export function exploreRouter( // GET /explore — Featured + Trending Side-by-Side. r.get('/explore', async (c) => { const db = dbOf(); + const userId = c.get('userId') as string | undefined; const [featured, trending] = await Promise.all([ - browseImpl(db, { sort: 'popular', limit: 8 }).then((r) => + browseImpl(db, { sort: 'popular', limit: 8 }, userId).then((r) => r.items.filter((d) => d.is_featured).slice(0, 8) ), - browseImpl(db, { sort: 'trending', limit: 8 }), + browseImpl(db, { sort: 'trending', limit: 8 }, userId), ]); return c.json({ featured, trending: trending.items }); }); @@ -198,7 +208,8 @@ export function exploreRouter( 422 ); } - const result = await browseImpl(dbOf(), parsed.data); + const userId = c.get('userId') as string | undefined; + const result = await browseImpl(dbOf(), parsed.data, userId); return c.json(result); }); diff --git a/apps/api/src/routes/marketplace/me.ts b/apps/api/src/routes/marketplace/me.ts new file mode 100644 index 0000000..76e5ac0 --- /dev/null +++ b/apps/api/src/routes/marketplace/me.ts @@ -0,0 +1,104 @@ +import { desc, eq } from 'drizzle-orm'; +import { Hono } from 'hono'; + +import { getDb, type CardsDb } from '../../db/connection.ts'; +import { authMiddleware, type AuthVars } from '../../middleware/auth.ts'; +import { + authorBlocks, + authors, + publicDeckVersions, + publicDecks, +} from '../../db/schema/index.ts'; + +/** + * Marketplace-Endpoints aus Sicht des angemeldeten Users: + * + * - `GET /me/decks` — eigene Marketplace-Decks mit aktueller Version. + * Wird vom Native-Re-Publish-Flow benutzt, um zu erkennen ob ein + * privater Deck schon ein Marketplace-Pendant hat. + * - `GET /me/blocks` — Liste der eigenen Author-Blocks (für die + * Block-Verwaltung in der App). + */ + +export type MarketplaceMeDeps = { db?: CardsDb }; + +export function marketplaceMeRouter(deps: MarketplaceMeDeps = {}): Hono<{ Variables: AuthVars }> { + const r = new Hono<{ Variables: AuthVars }>(); + const dbOf = () => deps.db ?? getDb(); + + r.use('*', authMiddleware); + + r.get('/decks', async (c) => { + const userId = c.get('userId'); + const db = dbOf(); + + const rows = await db + .select({ + slug: publicDecks.slug, + title: publicDecks.title, + description: publicDecks.description, + language: publicDecks.language, + category: publicDecks.category, + license: publicDecks.license, + priceCredits: publicDecks.priceCredits, + isTakedown: publicDecks.isTakedown, + createdAt: publicDecks.createdAt, + latestSemver: publicDeckVersions.semver, + latestCardCount: publicDeckVersions.cardCount, + latestVersionId: publicDeckVersions.id, + latestPublishedAt: publicDeckVersions.publishedAt, + }) + .from(publicDecks) + .leftJoin(publicDeckVersions, eq(publicDeckVersions.id, publicDecks.latestVersionId)) + .where(eq(publicDecks.ownerUserId, userId)) + .orderBy(desc(publicDecks.createdAt)); + + return c.json({ + decks: rows.map((row) => ({ + slug: row.slug, + title: row.title, + description: row.description, + language: row.language, + category: row.category, + license: row.license, + price_credits: row.priceCredits, + is_takedown: row.isTakedown, + created_at: row.createdAt.toISOString(), + latest_version: row.latestVersionId + ? { + version_id: row.latestVersionId, + semver: row.latestSemver, + card_count: row.latestCardCount, + published_at: row.latestPublishedAt?.toISOString() ?? null, + } + : null, + })), + }); + }); + + r.get('/blocks', async (c) => { + const userId = c.get('userId'); + const db = dbOf(); + + const rows = await db + .select({ + slug: authors.slug, + displayName: authors.displayName, + createdAt: authorBlocks.createdAt, + }) + .from(authorBlocks) + .innerJoin(authors, eq(authors.userId, authorBlocks.blockedUserId)) + .where(eq(authorBlocks.blockerUserId, userId)) + .orderBy(desc(authorBlocks.createdAt)); + + return c.json({ + blocks: rows.map((row) => ({ + author_slug: row.slug, + display_name: row.displayName, + blocked_at: row.createdAt.toISOString(), + })), + }); + }); + + return r; +} diff --git a/apps/api/src/routes/marketplace/moderation.ts b/apps/api/src/routes/marketplace/moderation.ts new file mode 100644 index 0000000..1b2eedb --- /dev/null +++ b/apps/api/src/routes/marketplace/moderation.ts @@ -0,0 +1,137 @@ +import { and, eq } from 'drizzle-orm'; +import { Hono } from 'hono'; +import { z } from 'zod'; + +import { getDb, type CardsDb } from '../../db/connection.ts'; +import { authMiddleware, type AuthVars } from '../../middleware/auth.ts'; +import { rateLimit, userKey } from '../../middleware/rate-limit.ts'; +import { authorBlocks, authors, deckReports, publicDecks } from '../../db/schema/index.ts'; + +/** + * Moderation-Endpoints — App-Review-Pflicht für User-Generated-Content + * (App-Store-Guideline 5.1.1(v)). Aus Cardecky-Native erreichbar als + * „Melden" und „Author blockieren". + * + * - `POST /decks/:slug/report` — Meldung zu einem Deck. Idempotent + * pro (deck, reporter, category) — Doppel-Reports geben 200 mit + * `already_reported: true` zurück. + * - `POST /authors/:slug/block` — Author blockieren. Decks des + * Authors verschwinden aus den Marketplace-Listings für den + * blockenden User. Idempotent. + * - `DELETE /authors/:slug/block` — Block aufheben. + * - `GET /me/blocks` — eigene Block-Liste. + */ + +export type ModerationDeps = { db?: CardsDb }; + +const ReportSchema = z.object({ + category: z.enum(['spam', 'copyright', 'nsfw', 'misinformation', 'hate', 'other']), + body: z.string().max(1000).optional(), + versionId: z.string().uuid().optional(), + cardContentHash: z.string().max(128).optional(), +}); + +export function moderationRouter(deps: ModerationDeps = {}): Hono<{ Variables: AuthVars }> { + const r = new Hono<{ Variables: AuthVars }>(); + const dbOf = () => deps.db ?? getDb(); + + r.use('*', authMiddleware); + r.use('/decks/:slug/report', rateLimit({ scope: 'marketplace.report', windowMs: 60_000, max: 10, keyOf: userKey })); + r.use('/authors/:slug/block', rateLimit({ scope: 'marketplace.block', windowMs: 60_000, max: 30, keyOf: userKey })); + + r.post('/decks/:slug/report', async (c) => { + const userId = c.get('userId'); + const slug = c.req.param('slug'); + const body = await c.req.json().catch(() => null); + const parsed = ReportSchema.safeParse(body); + if (!parsed.success) { + return c.json( + { error: 'invalid_input', issues: parsed.error.issues.map((i) => i.message) }, + 422, + ); + } + + const db = dbOf(); + const [deck] = await db.select().from(publicDecks).where(eq(publicDecks.slug, slug)).limit(1); + if (!deck) return c.json({ error: 'not_found' }, 404); + + // Owner darf das eigene Deck nicht melden. + if (deck.ownerUserId === userId) { + return c.json({ error: 'cannot_report_own_deck' }, 422); + } + + // Idempotent: gibt es schon einen offenen Report von diesem User + // mit gleicher Kategorie? Dann nur „already_reported" zurückgeben, + // kein zweites Insert. + const [existing] = await db + .select() + .from(deckReports) + .where( + and( + eq(deckReports.deckId, deck.id), + eq(deckReports.reporterUserId, userId), + eq(deckReports.category, parsed.data.category), + eq(deckReports.status, 'open'), + ), + ) + .limit(1); + + if (existing) { + return c.json({ ok: true, already_reported: true, report_id: existing.id }); + } + + const [row] = await db + .insert(deckReports) + .values({ + deckId: deck.id, + versionId: parsed.data.versionId, + cardContentHash: parsed.data.cardContentHash, + reporterUserId: userId, + category: parsed.data.category, + body: parsed.data.body, + }) + .returning(); + + return c.json({ ok: true, already_reported: false, report_id: row.id }, 201); + }); + + r.post('/authors/:slug/block', async (c) => { + const userId = c.get('userId'); + const slug = c.req.param('slug'); + const db = dbOf(); + + const [author] = await db.select().from(authors).where(eq(authors.slug, slug)).limit(1); + if (!author) return c.json({ error: 'not_found' }, 404); + if (author.userId === userId) { + return c.json({ error: 'cannot_block_self' }, 422); + } + + await db + .insert(authorBlocks) + .values({ blockerUserId: userId, blockedUserId: author.userId }) + .onConflictDoNothing({ target: [authorBlocks.blockerUserId, authorBlocks.blockedUserId] }); + + return c.json({ ok: true }); + }); + + r.delete('/authors/:slug/block', async (c) => { + const userId = c.get('userId'); + const slug = c.req.param('slug'); + const db = dbOf(); + + const [author] = await db.select().from(authors).where(eq(authors.slug, slug)).limit(1); + if (!author) return c.json({ error: 'not_found' }, 404); + + await db + .delete(authorBlocks) + .where( + and( + eq(authorBlocks.blockerUserId, userId), + eq(authorBlocks.blockedUserId, author.userId), + ), + ); + return c.json({ ok: true }); + }); + + return r; +}