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:
parent
e596199ba0
commit
9a7068dd19
17 changed files with 2404 additions and 4 deletions
|
|
@ -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;
|
||||
|
|
|
|||
23
apps/api/src/db/schema/marketplace/_schema.ts
Normal file
23
apps/api/src/db/schema/marketplace/_schema.ts
Normal 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');
|
||||
68
apps/api/src/db/schema/marketplace/authors.ts
Normal file
68
apps/api/src/db/schema/marketplace/authors.ts
Normal 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;
|
||||
68
apps/api/src/db/schema/marketplace/credits.ts
Normal file
68
apps/api/src/db/schema/marketplace/credits.ts
Normal 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),
|
||||
})
|
||||
);
|
||||
138
apps/api/src/db/schema/marketplace/decks.ts
Normal file
138
apps/api/src/db/schema/marketplace/decks.ts
Normal 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;
|
||||
81
apps/api/src/db/schema/marketplace/discussions.ts
Normal file
81
apps/api/src/db/schema/marketplace/discussions.ts
Normal 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),
|
||||
})
|
||||
);
|
||||
67
apps/api/src/db/schema/marketplace/engagement.ts
Normal file
67
apps/api/src/db/schema/marketplace/engagement.ts
Normal 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),
|
||||
})
|
||||
);
|
||||
19
apps/api/src/db/schema/marketplace/index.ts
Normal file
19
apps/api/src/db/schema/marketplace/index.ts
Normal 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';
|
||||
83
apps/api/src/db/schema/marketplace/moderation.ts
Normal file
83
apps/api/src/db/schema/marketplace/moderation.ts
Normal 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),
|
||||
})
|
||||
);
|
||||
48
apps/api/src/db/schema/marketplace/tags.ts
Normal file
48
apps/api/src/db/schema/marketplace/tags.ts
Normal 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),
|
||||
})
|
||||
);
|
||||
Loading…
Add table
Add a link
Reference in a new issue