Phase 12 R0+R1: Marketplace-Restore-Plan + Schema in marketplace-pgSchema

R0 (Doku):
- Archiv unter docs/marketplace/archive/ aus managarten-Tag
  cards-decommission-base: MARKETPLACE_PLAN (654 Z., Vollvision mit
  mana-credits-Flow, Anti-Patterns), COMPETITORS, GUIDELINES,
  cards-server_CLAUDE.
- docs/playbooks/MARKETPLACE_RESTORE.md mit Schema-Naming-Entscheidung
  (eigenes marketplace-pgSchema), Wellen R0-R6, Cardecky-Skill-
  Integration, Lizenz-Modell.
- CLAUDE.md Invariante 2: Strategie-B gilt nur für Study-/FSRS-/Sync-
  Schicht; Marketplace-Restore ist explizite Ausnahme.
- STATUS.md: Phase 12 R0+R1 durch.

R1 (Schema):
- 16 Tabellen + 5 Enums im neuen marketplace-pgSchema (authors,
  decks, deck_versions, deck_cards, tag_definitions, deck_tags,
  deck_stars, deck_subscriptions, deck_forks, deck_pull_requests,
  card_discussions, deck_reports, ai_moderation_log, deck_purchases,
  author_payouts, author_follows).
- drizzle.config.ts: schemaFilter ['cards', 'marketplace'].
- Greenfield cards-pgSchema unangetastet.
- DB-CHECK decks_price_requires_license verifiziert (paid Deck mit
  CC-BY wirft sauber ab).
- type-check + 56 API-Tests grün, drizzle-kit push idempotent.

Decks dormant (kein Code-Pfad ruft die Tabellen). R2 (Backend α/β:
Author-Profile + Publish + AI-Mod) als nächstes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-05-09 15:05:22 +02:00
parent e596199ba0
commit 9a7068dd19
17 changed files with 2404 additions and 4 deletions

View file

@ -1,13 +1,13 @@
import type { Config } from 'drizzle-kit';
export default {
schema: './src/db/schema/*.ts',
schema: ['./src/db/schema/*.ts', './src/db/schema/marketplace/*.ts'],
out: './src/db/migrations',
dialect: 'postgresql',
dbCredentials: {
url: process.env.DATABASE_URL ?? 'postgresql://cards:cards@localhost:5435/cards',
},
schemaFilter: ['cards'],
schemaFilter: ['cards', 'marketplace'],
verbose: true,
strict: true,
} satisfies Config;

View file

@ -0,0 +1,23 @@
import { pgSchema } from 'drizzle-orm/pg-core';
/**
* Public-Marketplace-Tabellen leben unter einem eigenen
* `marketplace`-pgSchema in derselben `cards`-DB. Bewusste Trennung
* zur privaten Lern-Welt (`cards`-pgSchema):
*
* - sauberer Read-Path: Public-Endpoints sehen nur `marketplace`,
* Greenfield-Code nur `cards`.
* - Backup-Granularität (`pg_dump --schema=marketplace`).
* - RLS-Policies pro Schema möglich (Take-Down-Workflows).
*
* Drizzle-Variablennamen tragen bewusst den `public`-Prefix
* (`publicDecks`, `publicDeckVersions`, ) um in Imports klar
* vom privaten `cards.decks` getrennt zu sein.
*
* Geschichte: 1:1 portiert aus dem alten
* `mana-monorepo/services/cards-server/src/db/schema/_schema.ts`
* (managarten-Tag `cards-decommission-base`, 2026-05-08), nur das
* Schema-Target gewechselt von `cards` auf `marketplace`. Plan:
* `docs/playbooks/MARKETPLACE_RESTORE.md`.
*/
export const marketplaceSchema = pgSchema('marketplace');

View file

@ -0,0 +1,68 @@
/**
* Authors Public-Identität für User, die Decks publishen.
*
* Eine Author-Row pro User, der jemals ein Author-Profil angelegt hat.
* `userId` ist plain-text-Referenz auf `auth.users.id` (cross-DB
* keine FK auf DB-Ebene; das konsumierende Service validiert JWTs
* von mana-auth und nimmt den `sub`-Claim verbatim).
*
* Verifizierung hat zwei orthogonale Achsen:
* - `verified_mana`: manuell vom Mana-Verein vergeben (Lehrer,
* Pädagog:innen, Ärzt:innen). Nicht erkaufbar.
* - `verified_community`: automatisch berechnet aus Engagement
* ( X stars, Y featured Decks, Z Subscribers). Periodisch
* re-evaluiert.
*
* Beide Achsen können gleichzeitig true sein UI zeigt beide Badges.
*/
import { boolean, index, text, timestamp, uniqueIndex } from 'drizzle-orm/pg-core';
import { marketplaceSchema } from './_schema.ts';
export const authors = marketplaceSchema.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-Modus: Klarname versteckt, nur displayName sichtbar.
pseudonym: boolean('pseudonym').default(false).notNull(),
// Verifizierungs-Flags (siehe Header).
verifiedMana: boolean('verified_mana').default(false).notNull(),
verifiedCommunity: boolean('verified_community').default(false).notNull(),
// Soft-Ban: gebannter Author kann nicht mehr publishen, existierende
// Decks bleiben lesbar mit „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-Beziehung User Author. Treibt den persönlichen Activity-Feed.
*/
export const authorFollows = marketplaceSchema.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) => ({
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),
})
);
export type AuthorRow = typeof authors.$inferSelect;
export type AuthorInsert = typeof authors.$inferInsert;

View file

@ -0,0 +1,68 @@
/**
* Two-sided Marketplace-Bookkeeping. Das eigentliche Geld lebt in
* mana-credits wir tracken nur das Deck-Purchase-Event und den
* abgeleiteten Author-Payout, damit Buyer-History, Author-Dashboards
* und Reconcile gegen den mana-credits-Ledger möglich sind.
*
* **Status R1 (Schema-Restore):** Tabellen kommen mit, aber Code-Pfade
* (Routes/Services) bleiben dormant bis nach Live-Validation der
* gratis-Decks. Re-Aktivierung als eigene Welle.
*/
import { index, integer, text, timestamp, uniqueIndex, uuid } from 'drizzle-orm/pg-core';
import { marketplaceSchema } from './_schema.ts';
import { authors } from './authors.ts';
import { publicDecks, publicDeckVersions } from './decks.ts';
export const deckPurchases = marketplaceSchema.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 der Version zum Kaufzeitpunkt — Buyer behält Lifetime-
// Access auf alle nachfolgenden Versionen.
versionId: uuid('version_id')
.notNull()
.references(() => publicDeckVersions.id, { onDelete: 'restrict' }),
// Snapshot des Preises zum Kaufzeitpunkt.
priceCredits: integer('price_credits').notNull(),
// Pre-computed Split (Sum = priceCredits modulo Rundung).
authorShare: integer('author_share').notNull(),
manaShare: integer('mana_share').notNull(),
// Reference in mana-credits-Ledger.
creditsTransaction: text('credits_transaction'),
purchasedAt: timestamp('purchased_at', { withTimezone: true }).defaultNow().notNull(),
refundedAt: timestamp('refunded_at', { withTimezone: true }),
},
(t) => ({
// Ein Kauf pro Buyer pro Deck — covered 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 = marketplaceSchema.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 in mana-credits-Grant-Ledger.
creditsGrantId: text('credits_grant_id'),
grantedAt: timestamp('granted_at', { withTimezone: true }).defaultNow().notNull(),
},
(t) => ({
authorIdx: index('author_payouts_author_idx').on(t.authorUserId),
purchaseIdx: index('author_payouts_purchase_idx').on(t.sourcePurchaseId),
})
);

View file

@ -0,0 +1,138 @@
/**
* Public-Decks, Versionen, Karten.
*
* Ein Deck ist das langlebige Identitäts-Objekt (Spanish A2 Vocab"),
* zeigt immer auf eine `latest_version_id`. Versionen sind immutable
* Snapshots einmal published, ändern sie sich nie. Karten sind
* Versions-skopiert und tragen einen per-Karten-Content-Hash, damit
* Subscriber-Smart-Merge den FSRS-State unveränderter Karten über
* Versions-Bumps hinweg erhalten kann.
*
* `price_credits = 0` heißt kostenlos. Alles > 0 erzwingt die
* Cardecky-Pro-Only-1.0-Lizenz (CHECK constraint auf DB-Ebene).
*/
import { sql } from 'drizzle-orm';
import {
boolean,
check,
index,
integer,
jsonb,
text,
timestamp,
uniqueIndex,
uuid,
} from 'drizzle-orm/pg-core';
import { marketplaceSchema } from './_schema.ts';
import { authors } from './authors.ts';
/**
* Spiegelt `CardType` aus `@cards/domain`. Enum lebt im
* `marketplace`-pgSchema (nicht im default `public`), damit
* drizzle-kit-Push mit `schemaFilter: ['cards', 'marketplace']`
* keine spurious CREATE-TYPE-Versuche macht.
*/
export const cardTypeEnum = marketplaceSchema.enum('card_type', [
'basic',
'basic-reverse',
'cloze',
'type-in',
'image-occlusion',
'audio',
'multiple-choice',
]);
export const publicDecks = marketplaceSchema.table(
'decks',
{
id: uuid('id').primaryKey().defaultRandom(),
slug: text('slug').notNull(),
title: text('title').notNull(),
description: text('description'),
// ISO-639-1 (z.B. 'de', 'en', 'es'). Nullable für mixed-language.
language: text('language'),
// SPDX-style ID. CC0-1.0, CC-BY-4.0, CC-BY-SA-4.0,
// Cardecky-Personal-Use-1.0 (default für 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 bei jedem Publish einer neuen Version.
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 müssen die Pro-Only-Lizenz tragen.
priceLicense: check(
'decks_price_requires_license',
sql`price_credits = 0 OR license = 'Cardecky-Pro-Only-1.0'`
),
})
);
export const publicDeckVersions = marketplaceSchema.table(
'deck_versions',
{
id: uuid('id').primaryKey().defaultRandom(),
deckId: uuid('deck_id')
.notNull()
.references(() => publicDecks.id, { onDelete: 'cascade' }),
// SemVer string — ordering passiert in app code, nicht in der DB.
semver: text('semver').notNull(),
changelog: text('changelog'),
// SHA-256 über die canonicalized Karten-Liste — Clients erkennen
// damit „hat sich was geändert" ohne Payload-Diff.
contentHash: text('content_hash').notNull(),
cardCount: integer('card_count').notNull(),
publishedAt: timestamp('published_at', { withTimezone: true }).defaultNow().notNull(),
// Ältere Versionen bleiben lesbar, neue Subscriber gehen auf 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 = marketplaceSchema.table(
'deck_cards',
{
id: uuid('id').primaryKey().defaultRandom(),
versionId: uuid('version_id')
.notNull()
.references(() => publicDeckVersions.id, { onDelete: 'cascade' }),
// Spiegelt @cards/domain CardType.
type: cardTypeEnum('type').notNull(),
// Free-form key/value bag.
// basic / basic-reverse / type-in: { front, back }
// cloze: { text, extra? }
fields: jsonb('fields').$type<Record<string, string>>().notNull(),
ord: integer('ord').notNull(),
// SHA-256 über canonical(type, fields). Subscriber nutzen den
// Hash für per-Karten-Smart-Merge — unveränderte Karten behalten
// ihren FSRS-State über Versions-Pulls hinweg. **Identisch zur
// Berechnung in `@cards/domain` `cardContentHash`** — das ist der
// SoT, dieser Hash ist nur die persistierte Form.
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),
})
);
export type PublicDeckRow = typeof publicDecks.$inferSelect;
export type PublicDeckInsert = typeof publicDecks.$inferInsert;
export type PublicDeckVersionRow = typeof publicDeckVersions.$inferSelect;
export type PublicDeckCardRow = typeof publicDeckCards.$inferSelect;

View file

@ -0,0 +1,81 @@
/**
* Pull-Requests + Card-Discussions.
*
* Pull-Requests propose card-level Änderungen am Deck; der Author kann
* mergen cards-server erstellt eine neue Version automatisch. Der
* Diff wird als JSON-Blob (`{ add, modify, remove }`) gespeichert,
* damit eine GitHub-style Review-UI gerendert werden kann ohne aus
* Versions-Diffs neu abzuleiten.
*
* Card-Discussions sind an `card_content_hash` gebunden (nicht an
* `card_id`), damit Threads Versions-Bumps überleben solange die
* Karte selbst unverändert bleibt.
*/
import { boolean, index, jsonb, text, timestamp, uuid } from 'drizzle-orm/pg-core';
import { marketplaceSchema } from './_schema.ts';
import { publicDecks, publicDeckVersions } from './decks.ts';
export const pullRequestStatusEnum = marketplaceSchema.enum('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 = marketplaceSchema.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 = marketplaceSchema.table(
'card_discussions',
{
id: uuid('id').primaryKey().defaultRandom(),
// Bound to card content_hash, not row id, so der Thread folgt der
// Karte über Versions-Bumps solange Inhalt bleibt.
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 Mod. Nicht gelöscht — Audit-Trail erhalten.
hidden: boolean('hidden').notNull().default(false),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
},
(t) => ({
hashIdx: index('card_discussions_hash_idx').on(t.cardContentHash),
deckIdx: index('card_discussions_deck_idx').on(t.deckId),
parentIdx: index('card_discussions_parent_idx').on(t.parentId),
})
);

View file

@ -0,0 +1,67 @@
/**
* Engagement-Primitives Stars (Bookmarks), Subscriptions (Live-
* Updates), Forks (eigene Kopie mit Lineage).
*
* Alle keyed auf `user_id` text plain reference auf auth.users.id
* ohne cross-DB-FK.
*/
import { boolean, index, text, timestamp, uniqueIndex, uuid } from 'drizzle-orm/pg-core';
import { marketplaceSchema } from './_schema.ts';
import { publicDecks, publicDeckVersions } from './decks.ts';
export const deckStars = marketplaceSchema.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 = marketplaceSchema.table(
'deck_subscriptions',
{
userId: text('user_id').notNull(),
deckId: uuid('deck_id')
.notNull()
.references(() => publicDecks.id, { onDelete: 'cascade' }),
// Letzte Version, die der User gepullt hat. Smart-Merge vergleicht
// das mit `deck.latest_version_id` für den 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 = marketplaceSchema.table(
'deck_forks',
{
userId: text('user_id').notNull(),
sourceDeckId: uuid('source_deck_id')
.notNull()
.references(() => publicDecks.id, { onDelete: 'cascade' }),
sourceVersionId: uuid('source_version_id')
.notNull()
.references(() => publicDeckVersions.id, { onDelete: 'cascade' }),
forkedAt: timestamp('forked_at', { withTimezone: true }).defaultNow().notNull(),
},
(t) => ({
pk: uniqueIndex('deck_forks_pk').on(t.userId, t.sourceDeckId, t.sourceVersionId),
sourceIdx: index('deck_forks_source_idx').on(t.sourceDeckId),
})
);

View file

@ -0,0 +1,19 @@
/**
* Re-Exports für das gesamte Marketplace-Schema. Hält Imports flach
* downstream code macht
* `import { authors, publicDecks } from '../db/schema/marketplace'`.
*
* **Naming-Konvention:** Variablennamen tragen `public`-Prefix
* (`publicDecks`, `publicDeckVersions`, `publicDeckCards`), damit
* Imports zusammen mit den Greenfield-`cards.decks`/`cards.cards` aus
* `../schema/index.ts` nicht kollidieren.
*/
export { marketplaceSchema } from './_schema.ts';
export * from './authors.ts';
export * from './decks.ts';
export * from './tags.ts';
export * from './engagement.ts';
export * from './discussions.ts';
export * from './moderation.ts';
export * from './credits.ts';

View file

@ -0,0 +1,83 @@
/**
* Moderation User-Reports + AI-First-Pass-Log.
*
* Reports fließen in eine Mana-Admin-Inbox; AI-Mod-Log ist ein Record
* jeder automatisierten Prüfung pro Version, damit wir auditieren /
* re-train können wenn ein bad outcome shipped.
*/
import { boolean, index, text, timestamp, uuid } from 'drizzle-orm/pg-core';
import { marketplaceSchema } from './_schema.ts';
import { publicDecks, publicDeckVersions } from './decks.ts';
export const reportCategoryEnum = marketplaceSchema.enum('report_category', [
'spam',
'copyright',
'nsfw',
'misinformation',
'hate',
'other',
]);
export const reportStatusEnum = marketplaceSchema.enum('report_status', [
'open',
'dismissed',
'actioned',
]);
export const deckReports = marketplaceSchema.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 auf eine spezifische Karte 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 = marketplaceSchema.enum('ai_mod_verdict', [
'pass',
'flag',
'block',
]);
export const aiModerationLog = marketplaceSchema.table(
'ai_moderation_log',
{
id: uuid('id').primaryKey().defaultRandom(),
versionId: uuid('version_id')
.notNull()
.references(() => publicDeckVersions.id, { onDelete: 'cascade' }),
verdict: aiModerationVerdictEnum('verdict').notNull(),
// Categories die das Modell flagged — array weil ein Verdict
// mehrere Kategorien hitten kann (z.B. „spam" + „misinformation").
categories: text('categories').array(),
model: text('model'),
rationale: text('rationale'),
humanReviewed: boolean('human_reviewed').notNull().default(false),
humanOverrode: boolean('human_overrode').notNull().default(false),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
},
(t) => ({
versionIdx: index('ai_moderation_log_version_idx').on(t.versionId),
verdictIdx: index('ai_moderation_log_verdict_idx').on(t.verdict),
})
);

View file

@ -0,0 +1,48 @@
/**
* Tag-Taxonomie für den Marketplace. Hierarchisch (parent_id), kuratiert
* vom Mana-Verein für den kanonischen Baum (medizin > anatomie > kardio).
* Authoren picken aus existierenden Tags und können neue über einen
* moderierten Flow vorschlagen (`curated = false` Admin reviewed).
*
* Bewusst getrennt von der bestehenden `cards.tags`-Tabelle (privat,
* Deck-skopiert) anderer Use-Case (Discovery vs. Lokal-Organisation),
* andere Lebenszyklen.
*/
import { boolean, index, text, timestamp, uniqueIndex, uuid } from 'drizzle-orm/pg-core';
import { marketplaceSchema } from './_schema.ts';
import { publicDecks } from './decks.ts';
export const tagDefinitions = marketplaceSchema.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 = marketplaceSchema.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),
})
);