mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-23 07:06:41 +02:00
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:
parent
33bc654238
commit
a7b62ea8ae
26 changed files with 3407 additions and 140 deletions
72
services/cards-server/src/config.ts
Normal file
72
services/cards-server/src/config.ts
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
/**
|
||||
* Runtime config — read once at startup, validated with sensible
|
||||
* dev-friendly defaults but loud in prod when secrets are missing.
|
||||
*/
|
||||
|
||||
export interface Config {
|
||||
port: number;
|
||||
databaseUrl: string;
|
||||
manaAuthUrl: string;
|
||||
manaCreditsUrl: string;
|
||||
manaLlmUrl: string;
|
||||
manaMediaUrl: string;
|
||||
manaNotifyUrl: string;
|
||||
serviceKey: string;
|
||||
cors: { origins: string[] };
|
||||
authorPayout: {
|
||||
standardAuthorBps: number;
|
||||
verifiedAuthorBps: number;
|
||||
};
|
||||
communityVerifiedThresholds: {
|
||||
stars: number;
|
||||
featuredDecks: number;
|
||||
activeSubscribers: number;
|
||||
};
|
||||
}
|
||||
|
||||
function getEnv(key: string, fallback?: string): string {
|
||||
const v = process.env[key];
|
||||
if (v && v.length > 0) return v;
|
||||
if (fallback !== undefined) return fallback;
|
||||
throw new Error(`Missing required env var: ${key}`);
|
||||
}
|
||||
|
||||
function getEnvNumber(key: string, fallback: number): number {
|
||||
const v = process.env[key];
|
||||
if (!v) return fallback;
|
||||
const n = Number(v);
|
||||
if (Number.isNaN(n)) throw new Error(`${key} is not a number: ${v}`);
|
||||
return n;
|
||||
}
|
||||
|
||||
export function loadConfig(): Config {
|
||||
const inProd = process.env.NODE_ENV === 'production';
|
||||
|
||||
return {
|
||||
port: getEnvNumber('PORT', 3072),
|
||||
databaseUrl: getEnv(
|
||||
'DATABASE_URL',
|
||||
inProd ? undefined : 'postgresql://mana:devpassword@localhost:5432/mana_platform'
|
||||
),
|
||||
manaAuthUrl: getEnv('MANA_AUTH_URL', 'http://localhost:3001'),
|
||||
manaCreditsUrl: getEnv('MANA_CREDITS_URL', 'http://localhost:3061'),
|
||||
manaLlmUrl: getEnv('MANA_LLM_URL', 'http://localhost:3025'),
|
||||
manaMediaUrl: getEnv('MANA_MEDIA_URL', 'http://localhost:3015'),
|
||||
manaNotifyUrl: getEnv('MANA_NOTIFY_URL', 'http://localhost:3040'),
|
||||
serviceKey: getEnv('MANA_SERVICE_KEY', inProd ? undefined : 'dev-service-key'),
|
||||
cors: {
|
||||
origins: getEnv('CORS_ORIGINS', 'http://localhost:5173,http://localhost:5180').split(','),
|
||||
},
|
||||
authorPayout: {
|
||||
// 80/20 standard, 90/10 for verified-mana authors. Stored in
|
||||
// basis-points so we can tune later without code change.
|
||||
standardAuthorBps: getEnvNumber('AUTHOR_PAYOUT_STANDARD_BPS', 8000),
|
||||
verifiedAuthorBps: getEnvNumber('AUTHOR_PAYOUT_VERIFIED_BPS', 9000),
|
||||
},
|
||||
communityVerifiedThresholds: {
|
||||
stars: getEnvNumber('COMMUNITY_VERIFY_STARS', 500),
|
||||
featuredDecks: getEnvNumber('COMMUNITY_VERIFY_FEATURED', 3),
|
||||
activeSubscribers: getEnvNumber('COMMUNITY_VERIFY_SUBSCRIBERS', 200),
|
||||
},
|
||||
};
|
||||
}
|
||||
23
services/cards-server/src/db/connection.ts
Normal file
23
services/cards-server/src/db/connection.ts
Normal 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>;
|
||||
10
services/cards-server/src/db/schema/_schema.ts
Normal file
10
services/cards-server/src/db/schema/_schema.ts
Normal 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');
|
||||
66
services/cards-server/src/db/schema/authors.ts
Normal file
66
services/cards-server/src/db/schema/authors.ts
Normal 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),
|
||||
})
|
||||
);
|
||||
63
services/cards-server/src/db/schema/credits.ts
Normal file
63
services/cards-server/src/db/schema/credits.ts
Normal 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),
|
||||
})
|
||||
);
|
||||
126
services/cards-server/src/db/schema/decks.ts
Normal file
126
services/cards-server/src/db/schema/decks.ts
Normal 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),
|
||||
})
|
||||
);
|
||||
79
services/cards-server/src/db/schema/discussions.ts
Normal file
79
services/cards-server/src/db/schema/discussions.ts
Normal 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),
|
||||
})
|
||||
);
|
||||
66
services/cards-server/src/db/schema/engagement.ts
Normal file
66
services/cards-server/src/db/schema/engagement.ts
Normal 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),
|
||||
})
|
||||
);
|
||||
13
services/cards-server/src/db/schema/index.ts
Normal file
13
services/cards-server/src/db/schema/index.ts
Normal 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';
|
||||
74
services/cards-server/src/db/schema/moderation.ts
Normal file
74
services/cards-server/src/db/schema/moderation.ts
Normal 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),
|
||||
})
|
||||
);
|
||||
43
services/cards-server/src/db/schema/tags.ts
Normal file
43
services/cards-server/src/db/schema/tags.ts
Normal 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),
|
||||
})
|
||||
);
|
||||
60
services/cards-server/src/index.ts
Normal file
60
services/cards-server/src/index.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
/**
|
||||
* cards-server — Cards Marketplace + Community backend.
|
||||
*
|
||||
* Hono + Bun. Owns published decks, versions, subscriptions, forks,
|
||||
* pull-requests, discussions, moderation, and the credits-based
|
||||
* author payout pipeline.
|
||||
*
|
||||
* See apps/cards/docs/MARKETPLACE_PLAN.md for the full design.
|
||||
*/
|
||||
|
||||
import { Hono } from 'hono';
|
||||
import { cors } from 'hono/cors';
|
||||
import { serviceErrorHandler as errorHandler } from '@mana/shared-hono';
|
||||
import { loadConfig } from './config';
|
||||
import { getDb } from './db/connection';
|
||||
import { healthRoutes } from './routes/health';
|
||||
|
||||
// ─── Bootstrap ──────────────────────────────────────────────
|
||||
|
||||
const config = loadConfig();
|
||||
// Eager-init the pool so a misconfigured DATABASE_URL fails at boot
|
||||
// (instead of on the first user request).
|
||||
getDb(config.databaseUrl);
|
||||
|
||||
// ─── App ────────────────────────────────────────────────────
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
app.onError(errorHandler);
|
||||
app.use(
|
||||
'*',
|
||||
cors({
|
||||
origin: config.cors.origins,
|
||||
credentials: true,
|
||||
})
|
||||
);
|
||||
|
||||
// Health (no auth)
|
||||
app.route('/health', healthRoutes);
|
||||
|
||||
// Versioned API surface — routes will land here in Phase α.3 onwards.
|
||||
// The /v1 prefix is the public contract from day one (see
|
||||
// MARKETPLACE_PLAN §3 architecture principle 1).
|
||||
const v1 = new Hono();
|
||||
v1.get('/', (c) =>
|
||||
c.json({
|
||||
service: 'cards-server',
|
||||
version: 1,
|
||||
message: 'See apps/cards/docs/MARKETPLACE_PLAN.md for the full plan.',
|
||||
})
|
||||
);
|
||||
app.route('/v1', v1);
|
||||
|
||||
// ─── Listen ────────────────────────────────────────────────
|
||||
|
||||
console.log(`[cards-server] listening on :${config.port}`);
|
||||
export default {
|
||||
port: config.port,
|
||||
fetch: app.fetch,
|
||||
};
|
||||
51
services/cards-server/src/lib/errors.ts
Normal file
51
services/cards-server/src/lib/errors.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
/**
|
||||
* Domain errors — caught by serviceErrorHandler from @mana/shared-hono
|
||||
* and translated to JSON responses with the right status code.
|
||||
*/
|
||||
|
||||
export class HttpError extends Error {
|
||||
constructor(
|
||||
public status: number,
|
||||
message: string,
|
||||
public code?: string,
|
||||
public details?: unknown
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'HttpError';
|
||||
}
|
||||
}
|
||||
|
||||
export class UnauthorizedError extends HttpError {
|
||||
constructor(message = 'Unauthorized') {
|
||||
super(401, message, 'unauthorized');
|
||||
this.name = 'UnauthorizedError';
|
||||
}
|
||||
}
|
||||
|
||||
export class ForbiddenError extends HttpError {
|
||||
constructor(message = 'Forbidden') {
|
||||
super(403, message, 'forbidden');
|
||||
this.name = 'ForbiddenError';
|
||||
}
|
||||
}
|
||||
|
||||
export class NotFoundError extends HttpError {
|
||||
constructor(message = 'Not found') {
|
||||
super(404, message, 'not_found');
|
||||
this.name = 'NotFoundError';
|
||||
}
|
||||
}
|
||||
|
||||
export class ConflictError extends HttpError {
|
||||
constructor(message = 'Conflict') {
|
||||
super(409, message, 'conflict');
|
||||
this.name = 'ConflictError';
|
||||
}
|
||||
}
|
||||
|
||||
export class BadRequestError extends HttpError {
|
||||
constructor(message = 'Bad request', details?: unknown) {
|
||||
super(400, message, 'bad_request', details);
|
||||
this.name = 'BadRequestError';
|
||||
}
|
||||
}
|
||||
56
services/cards-server/src/middleware/jwt-auth.ts
Normal file
56
services/cards-server/src/middleware/jwt-auth.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
/**
|
||||
* JWT authentication middleware — validates Bearer tokens via JWKS from
|
||||
* mana-auth (EdDSA, jose). Sets `c.set('user', { userId, email, role })`
|
||||
* on success.
|
||||
*
|
||||
* Mirrors the mana-credits middleware almost verbatim. Kept in-tree
|
||||
* rather than shared so we can evolve auth-related concerns (e.g.
|
||||
* audience claims) per service without coordination overhead.
|
||||
*/
|
||||
|
||||
import type { MiddlewareHandler } from 'hono';
|
||||
import { createRemoteJWKSet, jwtVerify } from 'jose';
|
||||
import { UnauthorizedError } from '../lib/errors';
|
||||
|
||||
let jwks: ReturnType<typeof createRemoteJWKSet> | null = null;
|
||||
|
||||
function getJwks(authUrl: string) {
|
||||
if (!jwks) {
|
||||
jwks = createRemoteJWKSet(new URL('/api/auth/jwks', authUrl));
|
||||
}
|
||||
return jwks;
|
||||
}
|
||||
|
||||
export interface AuthUser {
|
||||
userId: string;
|
||||
email: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
export function jwtAuth(authUrl: string): MiddlewareHandler {
|
||||
return async (c, next) => {
|
||||
const authHeader = c.req.header('Authorization');
|
||||
if (!authHeader?.startsWith('Bearer ')) {
|
||||
throw new UnauthorizedError('Missing or invalid Authorization header');
|
||||
}
|
||||
|
||||
const token = authHeader.slice(7);
|
||||
try {
|
||||
const { payload } = await jwtVerify(token, getJwks(authUrl), {
|
||||
issuer: authUrl,
|
||||
audience: 'mana',
|
||||
});
|
||||
|
||||
const user: AuthUser = {
|
||||
userId: payload.sub || '',
|
||||
email: (payload.email as string) || '',
|
||||
role: (payload.role as string) || 'user',
|
||||
};
|
||||
|
||||
c.set('user', user);
|
||||
await next();
|
||||
} catch {
|
||||
throw new UnauthorizedError('Invalid or expired token');
|
||||
}
|
||||
};
|
||||
}
|
||||
18
services/cards-server/src/middleware/service-auth.ts
Normal file
18
services/cards-server/src/middleware/service-auth.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
/**
|
||||
* Service-to-service authentication. Used by `/api/v1/internal/*`
|
||||
* routes that other Mana services call (e.g. mana-credits-webhook
|
||||
* pinging us about a confirmed payment).
|
||||
*/
|
||||
|
||||
import type { MiddlewareHandler } from 'hono';
|
||||
import { UnauthorizedError } from '../lib/errors';
|
||||
|
||||
export function serviceAuth(expectedKey: string): MiddlewareHandler {
|
||||
return async (c, next) => {
|
||||
const key = c.req.header('X-Service-Key');
|
||||
if (!key || key !== expectedKey) {
|
||||
throw new UnauthorizedError('Invalid X-Service-Key');
|
||||
}
|
||||
await next();
|
||||
};
|
||||
}
|
||||
11
services/cards-server/src/routes/health.ts
Normal file
11
services/cards-server/src/routes/health.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { Hono } from 'hono';
|
||||
|
||||
export const healthRoutes = new Hono();
|
||||
|
||||
healthRoutes.get('/', (c) => {
|
||||
return c.json({
|
||||
status: 'ok',
|
||||
service: 'cards-server',
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue