feat(cards-server): Phase α — service skeleton + 16-table schema

Lays the foundation for the Cards marketplace + community backend per
apps/cards/docs/MARKETPLACE_PLAN.md. Phase α scope: skeleton, schema,
JWT auth wiring, health endpoint. Routes follow in Phase β.

Stack: Hono + Bun + Drizzle + Postgres + jose-JWKS — mirrors the
mana-credits service template.

Schema: pgSchema('cards') inside mana_platform, 16 tables across six
groups in src/db/schema/:
  - authors.ts: authors, author_follows
  - decks.ts: decks, deck_versions, deck_cards (with cards_card_type
    enum mirroring @mana/cards-core; per-card content_hash for
    smart-merge; CHECK constraint that paid decks must use
    Cards-Pro-Only-1.0 license)
  - tags.ts: tag_definitions (hierarchical), deck_tags
  - engagement.ts: deck_stars, deck_subscriptions, deck_forks
  - discussions.ts: deck_pull_requests (with diff jsonb +
    pr_status enum), card_discussions (bound to card_content_hash
    so threads survive version bumps)
  - moderation.ts: deck_reports (with category/status enums),
    ai_moderation_log
  - credits.ts: deck_purchases (snapshot price + author/mana split),
    author_payouts

Phase λ's co_learn_sessions intentionally not yet here.

Service plumbing:
  - src/index.ts: Hono entry on :3072, /health unauth, /v1 stub
  - src/config.ts: env loader with author-payout BPS knobs
    (defaults 80/20 standard, 90/10 verified-mana) and
    community-verified thresholds
  - src/middleware/jwt-auth.ts + service-auth.ts: JWKS validation
    + X-Service-Key check (mirrors mana-credits)
  - src/lib/errors.ts: HttpError + named subclasses
  - drizzle.config.ts pointing at mana_platform with schemaFilter:cards
  - drizzle/0000_*.sql committed so other devs / prod migration path
    has a reproducible starting point

Validated: tsc --noEmit clean, drizzle-kit generate produces
233-line SQL with all 16 tables + 5 enums + indexes.

Next (Phase α.4): Dockerfile + docker-compose + cloudflare tunnel
route cards-api.mana.how → :3072.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-05-07 16:01:08 +02:00
parent 33bc654238
commit a7b62ea8ae
26 changed files with 3407 additions and 140 deletions

View file

@ -0,0 +1,23 @@
import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
import * as schema from './schema';
let _db: ReturnType<typeof drizzle<typeof schema>> | null = null;
/**
* Lazy singleton caller passes the url, but reuses the pool across
* the lifetime of the process. drizzle-kit cli skips this and opens
* its own connection from drizzle.config.ts.
*/
export function getDb(url: string) {
if (_db) return _db;
const client = postgres(url, {
max: 10,
idle_timeout: 20,
connect_timeout: 10,
});
_db = drizzle(client, { schema });
return _db;
}
export type Database = ReturnType<typeof getDb>;

View file

@ -0,0 +1,10 @@
import { pgSchema } from 'drizzle-orm/pg-core';
/**
* All Cards-marketplace tables live under the `cards` Postgres schema
* inside `mana_platform`. This keeps the marketplace next to the rest
* of the per-app data (Mana convention: one schema per product) and
* lets the per-table FKs reference shared tables (e.g. `auth.users`)
* via plain text columns without cross-DB JOINs.
*/
export const cardsSchema = pgSchema('cards');

View file

@ -0,0 +1,66 @@
/**
* Authors public-facing identity layer for users who publish decks.
*
* One author row per user that has ever opted into being an author.
* `userId` is a plain text reference to `auth.users.id` (cross-DB,
* no FK at the DB level the consumer service validates JWTs from
* mana-auth and uses the `sub` claim verbatim).
*
* Verification has two orthogonal axes:
* - `verified_mana`: manually granted by Mana-Verein (teachers,
* professional educators, doctors, etc.). Not earnable.
* - `verified_community`: automatically calculated from engagement
* ( X stars across decks, Y featured decks, Z subscribers).
* Periodically re-evaluated.
*
* Both axes can be true at once the UI shows both badges.
*/
import { boolean, index, text, timestamp, uniqueIndex } from 'drizzle-orm/pg-core';
import { cardsSchema } from './_schema';
export const authors = cardsSchema.table(
'authors',
{
userId: text('user_id').primaryKey(),
slug: text('slug').notNull(),
displayName: text('display_name').notNull(),
bio: text('bio'),
avatarUrl: text('avatar_url'),
joinedAt: timestamp('joined_at', { withTimezone: true }).defaultNow().notNull(),
// Pseudonym mode: legal name stays hidden, only displayName visible.
pseudonym: boolean('pseudonym').default(false).notNull(),
// Verification flags (see header).
verifiedMana: boolean('verified_mana').default(false).notNull(),
verifiedCommunity: boolean('verified_community').default(false).notNull(),
// Soft-ban: blocked author can no longer publish, existing decks
// stay readable but get a "deactivated" badge.
bannedAt: timestamp('banned_at', { withTimezone: true }),
bannedReason: text('banned_reason'),
},
(t) => ({
slugIdx: uniqueIndex('authors_slug_idx').on(t.slug),
verifiedIdx: index('authors_verified_idx').on(t.verifiedMana, t.verifiedCommunity),
})
);
/**
* Following relationship between users (followers) and authors.
* Drives the personal activity feed.
*/
export const authorFollows = cardsSchema.table(
'author_follows',
{
followerUserId: text('follower_user_id').notNull(),
authorUserId: text('author_user_id')
.notNull()
.references(() => authors.userId, { onDelete: 'cascade' }),
since: timestamp('since', { withTimezone: true }).defaultNow().notNull(),
},
(t) => ({
// Composite primary key (user, author).
pk: uniqueIndex('author_follows_pk').on(t.followerUserId, t.authorUserId),
authorIdx: index('author_follows_author_idx').on(t.authorUserId),
followerIdx: index('author_follows_follower_idx').on(t.followerUserId),
})
);

View file

@ -0,0 +1,63 @@
/**
* Two-sided marketplace bookkeeping. The actual money lives in
* mana-credits we just record the deck-purchase event and the
* derived author payout so we can show buyer history, author
* dashboards, and reconcile against the mana-credits ledger.
*/
import { index, integer, text, timestamp, uniqueIndex, uuid } from 'drizzle-orm/pg-core';
import { cardsSchema } from './_schema';
import { authors } from './authors';
import { publicDecks, publicDeckVersions } from './decks';
export const deckPurchases = cardsSchema.table(
'deck_purchases',
{
id: uuid('id').primaryKey().defaultRandom(),
buyerUserId: text('buyer_user_id').notNull(),
deckId: uuid('deck_id')
.notNull()
.references(() => publicDecks.id, { onDelete: 'restrict' }),
// Snapshot the version at time of purchase — buyer keeps lifetime
// access to all subsequent versions.
versionId: uuid('version_id')
.notNull()
.references(() => publicDeckVersions.id, { onDelete: 'restrict' }),
// Snapshot of the price at the time of purchase.
priceCredits: integer('price_credits').notNull(),
// Pre-computed split (sum equals priceCredits modulo rounding).
authorShare: integer('author_share').notNull(),
manaShare: integer('mana_share').notNull(),
// Reference into mana-credits ledger.
creditsTransaction: text('credits_transaction'),
purchasedAt: timestamp('purchased_at', { withTimezone: true }).defaultNow().notNull(),
refundedAt: timestamp('refunded_at', { withTimezone: true }),
},
(t) => ({
// One purchase per buyer per deck — covers lifetime access.
uniqueBuyerDeck: uniqueIndex('deck_purchases_buyer_deck_idx').on(t.buyerUserId, t.deckId),
buyerIdx: index('deck_purchases_buyer_idx').on(t.buyerUserId),
deckIdx: index('deck_purchases_deck_idx').on(t.deckId),
})
);
export const authorPayouts = cardsSchema.table(
'author_payouts',
{
id: uuid('id').primaryKey().defaultRandom(),
authorUserId: text('author_user_id')
.notNull()
.references(() => authors.userId, { onDelete: 'restrict' }),
sourcePurchaseId: uuid('source_purchase_id')
.notNull()
.references(() => deckPurchases.id, { onDelete: 'restrict' }),
creditsGranted: integer('credits_granted').notNull(),
// Reference into mana-credits grant ledger.
creditsGrantId: text('credits_grant_id'),
grantedAt: timestamp('granted_at', { withTimezone: true }).defaultNow().notNull(),
},
(t) => ({
authorIdx: index('author_payouts_author_idx').on(t.authorUserId),
purchaseIdx: index('author_payouts_purchase_idx').on(t.sourcePurchaseId),
})
);

View file

@ -0,0 +1,126 @@
/**
* Decks, Versions, Cards.
*
* A deck is the long-lived thing the user identifies with ("Spanish
* A2 Vocab"). It always points at a `latest_version_id`. Versions are
* immutable snapshots once published, they never change. Cards are
* scoped to a version and carry a per-card content-hash so subscriber
* smart-merge can preserve FSRS state for unchanged cards across
* version bumps.
*
* `price_credits` of 0 means free. Anything > 0 forces the
* Cards-Pro-Only-1.0 license (CHECK constraint enforced at DB level).
*/
import {
boolean,
check,
index,
integer,
jsonb,
pgEnum,
text,
timestamp,
uniqueIndex,
uuid,
} from 'drizzle-orm/pg-core';
import { sql } from 'drizzle-orm';
import { cardsSchema } from './_schema';
import { authors } from './authors';
/** Mirrors `CardType` in @mana/cards-core. Phase-1 ships basic / basic-reverse / cloze / type-in. */
export const cardTypeEnum = pgEnum('cards_card_type', [
'basic',
'basic-reverse',
'cloze',
'type-in',
'image-occlusion',
'audio',
'multiple-choice',
]);
export const publicDecks = cardsSchema.table(
'decks',
{
id: uuid('id').primaryKey().defaultRandom(),
slug: text('slug').notNull(),
title: text('title').notNull(),
description: text('description'),
// ISO-639-1 (e.g. 'de', 'en', 'es'). Nullable for mixed-language decks.
language: text('language'),
// SPDX-style ID. CC0-1.0, CC-BY-4.0, CC-BY-SA-4.0,
// Cards-Personal-Use-1.0 (default for free), Cards-Pro-Only-1.0 (paid).
license: text('license').notNull().default('Cards-Personal-Use-1.0'),
priceCredits: integer('price_credits').notNull().default(0),
ownerUserId: text('owner_user_id')
.notNull()
.references(() => authors.userId, { onDelete: 'restrict' }),
// Updated each time a new version is published.
latestVersionId: uuid('latest_version_id'),
isFeatured: boolean('is_featured').notNull().default(false),
isTakedown: boolean('is_takedown').notNull().default(false),
takedownAt: timestamp('takedown_at', { withTimezone: true }),
takedownReason: text('takedown_reason'),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
},
(t) => ({
slugIdx: uniqueIndex('decks_slug_idx').on(t.slug),
ownerIdx: index('decks_owner_idx').on(t.ownerUserId),
featuredIdx: index('decks_featured_idx').on(t.isFeatured),
// Paid decks must carry the Pro-Only license.
priceLicense: check(
'decks_price_requires_license',
sql`price_credits = 0 OR license = 'Cards-Pro-Only-1.0'`
),
})
);
export const publicDeckVersions = cardsSchema.table(
'deck_versions',
{
id: uuid('id').primaryKey().defaultRandom(),
deckId: uuid('deck_id')
.notNull()
.references(() => publicDecks.id, { onDelete: 'cascade' }),
// SemVer string — ordering done in app code, not DB.
semver: text('semver').notNull(),
changelog: text('changelog'),
// SHA-256 over the canonicalized card list — clients use this to
// detect "did anything change" without diffing payloads.
contentHash: text('content_hash').notNull(),
cardCount: integer('card_count').notNull(),
publishedAt: timestamp('published_at', { withTimezone: true }).defaultNow().notNull(),
// Older versions stay readable but new subscribers go to latest.
deprecatedAt: timestamp('deprecated_at', { withTimezone: true }),
},
(t) => ({
uniqueSemver: uniqueIndex('deck_versions_deck_semver_idx').on(t.deckId, t.semver),
deckIdx: index('deck_versions_deck_idx').on(t.deckId),
hashIdx: index('deck_versions_hash_idx').on(t.contentHash),
})
);
export const publicDeckCards = cardsSchema.table(
'deck_cards',
{
id: uuid('id').primaryKey().defaultRandom(),
versionId: uuid('version_id')
.notNull()
.references(() => publicDeckVersions.id, { onDelete: 'cascade' }),
// Mirrors @mana/cards-core CardType.
type: cardTypeEnum('type').notNull(),
// Free-form key/value bag of user content.
// basic / basic-reverse / type-in: { front, back }
// cloze: { text, extra? }
fields: jsonb('fields').$type<Record<string, string>>().notNull(),
ord: integer('ord').notNull(),
// SHA-256 over canonical(type, fields). Subscribers use this to
// detect per-card changes during smart-merge — unchanged cards
// keep their FSRS state across version pulls.
contentHash: text('content_hash').notNull(),
},
(t) => ({
uniqueOrd: uniqueIndex('deck_cards_version_ord_idx').on(t.versionId, t.ord),
hashIdx: index('deck_cards_hash_idx').on(t.contentHash),
})
);

View file

@ -0,0 +1,79 @@
/**
* Pull-Requests + Card-Discussions.
*
* Pull-requests propose card-level changes to a deck; the author can
* merge cards-server creates a new version automatically. The diff
* is stored as a JSON blob ({ add, modify, remove }) so we can render
* a GitHub-style review UI without re-deriving from version diffs.
*
* Card discussions are bound to `card_content_hash` (not `card_id`)
* so threads survive version bumps as long as the card itself stays
* unchanged.
*/
import { index, jsonb, text, timestamp, uuid, boolean, pgEnum } from 'drizzle-orm/pg-core';
import { cardsSchema } from './_schema';
import { publicDecks, publicDeckVersions } from './decks';
export const pullRequestStatusEnum = pgEnum('cards_pr_status', [
'open',
'merged',
'closed',
'rejected',
]);
export interface PullRequestDiff {
add: { type: string; fields: Record<string, string> }[];
modify: { contentHash: string; fields: Record<string, string> }[];
remove: { contentHash: string }[];
}
export const deckPullRequests = cardsSchema.table(
'deck_pull_requests',
{
id: uuid('id').primaryKey().defaultRandom(),
deckId: uuid('deck_id')
.notNull()
.references(() => publicDecks.id, { onDelete: 'cascade' }),
authorUserId: text('author_user_id').notNull(),
status: pullRequestStatusEnum('status').notNull().default('open'),
title: text('title').notNull(),
body: text('body'),
diff: jsonb('diff').$type<PullRequestDiff>().notNull(),
mergedIntoVersionId: uuid('merged_into_version_id').references(() => publicDeckVersions.id, {
onDelete: 'set null',
}),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
resolvedAt: timestamp('resolved_at', { withTimezone: true }),
},
(t) => ({
deckIdx: index('deck_pull_requests_deck_idx').on(t.deckId),
statusIdx: index('deck_pull_requests_status_idx').on(t.deckId, t.status),
authorIdx: index('deck_pull_requests_author_idx').on(t.authorUserId),
})
);
export const cardDiscussions = cardsSchema.table(
'card_discussions',
{
id: uuid('id').primaryKey().defaultRandom(),
// Bound to the card's content_hash, not its row id, so the thread
// follows the card across version bumps as long as content stays.
cardContentHash: text('card_content_hash').notNull(),
deckId: uuid('deck_id')
.notNull()
.references(() => publicDecks.id, { onDelete: 'cascade' }),
authorUserId: text('author_user_id').notNull(),
// Threading: parent_id NULL = root post, NOT NULL = reply.
parentId: uuid('parent_id'),
body: text('body').notNull(),
// Hidden by author or moderator. Not deleted — preserves audit trail.
hidden: boolean('hidden').notNull().default(false),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
},
(t) => ({
hashIdx: index('card_discussions_hash_idx').on(t.cardContentHash),
deckIdx: index('card_discussions_deck_idx').on(t.deckId),
parentIdx: index('card_discussions_parent_idx').on(t.parentId),
})
);

View file

@ -0,0 +1,66 @@
/**
* Engagement primitives Stars (bookmarks), Subscriptions (live
* updates), Forks (own copy with lineage).
*
* All keyed by `user_id` text a plain reference to auth.users.id
* with no cross-DB FK.
*/
import { boolean, index, timestamp, uniqueIndex, uuid, text } from 'drizzle-orm/pg-core';
import { cardsSchema } from './_schema';
import { publicDecks, publicDeckVersions } from './decks';
export const deckStars = cardsSchema.table(
'deck_stars',
{
userId: text('user_id').notNull(),
deckId: uuid('deck_id')
.notNull()
.references(() => publicDecks.id, { onDelete: 'cascade' }),
starredAt: timestamp('starred_at', { withTimezone: true }).defaultNow().notNull(),
},
(t) => ({
pk: uniqueIndex('deck_stars_pk').on(t.userId, t.deckId),
deckIdx: index('deck_stars_deck_idx').on(t.deckId),
})
);
export const deckSubscriptions = cardsSchema.table(
'deck_subscriptions',
{
userId: text('user_id').notNull(),
deckId: uuid('deck_id')
.notNull()
.references(() => publicDecks.id, { onDelete: 'cascade' }),
// Latest version the user has pulled. Smart-merge compares this to
// the deck's `latest_version_id` to compute the diff.
currentVersionId: uuid('current_version_id').references(() => publicDeckVersions.id, {
onDelete: 'set null',
}),
subscribedAt: timestamp('subscribed_at', { withTimezone: true }).defaultNow().notNull(),
notifyUpdates: boolean('notify_updates').notNull().default(true),
},
(t) => ({
pk: uniqueIndex('deck_subscriptions_pk').on(t.userId, t.deckId),
deckIdx: index('deck_subscriptions_deck_idx').on(t.deckId),
userIdx: index('deck_subscriptions_user_idx').on(t.userId),
})
);
export const deckForks = cardsSchema.table(
'deck_forks',
{
userId: text('user_id').notNull(),
sourceDeckId: uuid('source_deck_id')
.notNull()
.references(() => publicDecks.id, { onDelete: 'cascade' }),
sourceVersionId: uuid('source_version_id')
.notNull()
.references(() => publicDeckVersions.id, { onDelete: 'cascade' }),
forkedAt: timestamp('forked_at', { withTimezone: true }).defaultNow().notNull(),
},
(t) => ({
pk: uniqueIndex('deck_forks_pk').on(t.userId, t.sourceDeckId, t.sourceVersionId),
sourceIdx: index('deck_forks_source_idx').on(t.sourceDeckId),
})
);

View file

@ -0,0 +1,13 @@
/**
* Re-exports for the entire cards-server schema. Keep imports flat
* downstream code does `import { authors, publicDecks } from '../db/schema'`.
*/
export { cardsSchema } from './_schema';
export * from './authors';
export * from './decks';
export * from './tags';
export * from './engagement';
export * from './discussions';
export * from './moderation';
export * from './credits';

View file

@ -0,0 +1,74 @@
/**
* Moderation user-submitted reports + AI-first-pass log.
*
* Reports flow into a Mana-admin inbox; AI-mod-log is a record of every
* automated check we ran on a version so we can audit / re-train if a
* bad outcome shipped.
*/
import { boolean, index, pgEnum, text, timestamp, uuid } from 'drizzle-orm/pg-core';
import { cardsSchema } from './_schema';
import { publicDecks, publicDeckVersions } from './decks';
export const reportCategoryEnum = pgEnum('cards_report_category', [
'spam',
'copyright',
'nsfw',
'misinformation',
'hate',
'other',
]);
export const reportStatusEnum = pgEnum('cards_report_status', ['open', 'dismissed', 'actioned']);
export const deckReports = cardsSchema.table(
'deck_reports',
{
id: uuid('id').primaryKey().defaultRandom(),
deckId: uuid('deck_id')
.notNull()
.references(() => publicDecks.id, { onDelete: 'cascade' }),
versionId: uuid('version_id').references(() => publicDeckVersions.id, {
onDelete: 'set null',
}),
// Optional: report scoped to one specific card by content-hash.
cardContentHash: text('card_content_hash'),
reporterUserId: text('reporter_user_id').notNull(),
category: reportCategoryEnum('category').notNull(),
body: text('body'),
status: reportStatusEnum('status').notNull().default('open'),
resolvedBy: text('resolved_by'),
resolvedAt: timestamp('resolved_at', { withTimezone: true }),
resolutionNotes: text('resolution_notes'),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
},
(t) => ({
deckIdx: index('deck_reports_deck_idx').on(t.deckId),
statusIdx: index('deck_reports_status_idx').on(t.status),
})
);
export const aiModerationVerdictEnum = pgEnum('cards_ai_mod_verdict', ['pass', 'flag', 'block']);
export const aiModerationLog = cardsSchema.table(
'ai_moderation_log',
{
id: uuid('id').primaryKey().defaultRandom(),
versionId: uuid('version_id')
.notNull()
.references(() => publicDeckVersions.id, { onDelete: 'cascade' }),
verdict: aiModerationVerdictEnum('verdict').notNull(),
// Categories the model flagged — array because one verdict can hit
// multiple categories (e.g. "spam" + "misinformation").
categories: text('categories').array(),
model: text('model'),
rationale: text('rationale'),
humanReviewed: boolean('human_reviewed').notNull().default(false),
humanOverrode: boolean('human_overrode').notNull().default(false),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
},
(t) => ({
versionIdx: index('ai_moderation_log_version_idx').on(t.versionId),
verdictIdx: index('ai_moderation_log_verdict_idx').on(t.verdict),
})
);

View file

@ -0,0 +1,43 @@
/**
* Tag taxonomy. Hierarchical (parent_id), curated by the Mana-Verein
* for the canonical tree (medizin > anatomie > kardiologie). Authors
* pick from existing tags and can suggest new ones via a moderated
* flow (`curated = false` admin reviews before promoting).
*/
import { boolean, index, text, timestamp, uniqueIndex, uuid } from 'drizzle-orm/pg-core';
import { cardsSchema } from './_schema';
import { publicDecks } from './decks';
export const tagDefinitions = cardsSchema.table(
'tag_definitions',
{
id: uuid('id').primaryKey().defaultRandom(),
slug: text('slug').notNull(),
name: text('name').notNull(),
parentId: uuid('parent_id'),
description: text('description'),
curated: boolean('curated').notNull().default(false),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
},
(t) => ({
slugIdx: uniqueIndex('tag_definitions_slug_idx').on(t.slug),
parentIdx: index('tag_definitions_parent_idx').on(t.parentId),
})
);
export const deckTags = cardsSchema.table(
'deck_tags',
{
deckId: uuid('deck_id')
.notNull()
.references(() => publicDecks.id, { onDelete: 'cascade' }),
tagId: uuid('tag_id')
.notNull()
.references(() => tagDefinitions.id, { onDelete: 'cascade' }),
},
(t) => ({
pk: uniqueIndex('deck_tags_pk').on(t.deckId, t.tagId),
tagIdx: index('deck_tags_tag_idx').on(t.tagId),
})
);