mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 18:01:09 +02:00
feat(cards-server): Phase β — author profiles + deck init/publish
First user-facing surface on cards-server. Three endpoint groups:
Authors (/v1/authors):
- POST /me — upsert author profile (slug, displayName, bio,
avatarUrl, pseudonym). Slug validated for length, charset, and
against a small reserved-words list (admin, api, me, ...).
- GET /me — read own profile (returns null if not yet an author).
- GET /:slug — public profile (omits banned-reason, etc.)
Decks (/v1/decks):
- POST / — claim a slug + create the metadata-only deck row.
License defaults to Cards-Personal-Use-1.0; paid decks
(priceCredits > 0) must use Cards-Pro-Only-1.0 (CHECK constraint
+ service-side guard).
- GET /:slug — deck + latestVersion.
- POST /:slug/publish — version semver enforced strictly increasing,
AI-mod first-pass via mana-llm (block → 403; flag → publish + log
for human review; pass → publish silently). Per-card and per-
version SHA-256 content hashes computed; cards persisted; deck's
latest_version_id flipped atomically in a single transaction.
Helpers:
- lib/slug.ts — slugify (best-effort) + validateSlug (strict).
- lib/hash.ts — canonical SHA-256 over (type, fields) for cards
and (sorted, ord-stable) for versions.
- lib/ai-moderation.ts — mana-llm /v1/chat/completions wrapper
with system prompt that forces JSON output. Fail-open: if
mana-llm is down or returns malformed JSON, the verdict is
'flag' so a human reviewer catches it. Better slow than silent.
Index-mounting of /v1/authors and /v1/decks is gated behind jwtAuth.
Anonymous public reads (Phase γ optionalAuth middleware) come later.
Validated: tsc --noEmit clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
b03165ce97
commit
044d948155
8 changed files with 692 additions and 8 deletions
|
|
@ -13,18 +13,24 @@ import { cors } from 'hono/cors';
|
|||
import { serviceErrorHandler as errorHandler } from '@mana/shared-hono';
|
||||
import { loadConfig } from './config';
|
||||
import { getDb } from './db/connection';
|
||||
import { jwtAuth, type AuthUser } from './middleware/jwt-auth';
|
||||
import { healthRoutes } from './routes/health';
|
||||
import { AuthorService } from './services/authors';
|
||||
import { DeckService } from './services/decks';
|
||||
import { createAuthorRoutes } from './routes/authors';
|
||||
import { createDeckRoutes } from './routes/decks';
|
||||
|
||||
// ─── 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);
|
||||
const db = getDb(config.databaseUrl);
|
||||
|
||||
const authorService = new AuthorService(db);
|
||||
const deckService = new DeckService(db, config.manaLlmUrl);
|
||||
|
||||
// ─── App ────────────────────────────────────────────────────
|
||||
|
||||
const app = new Hono();
|
||||
const app = new Hono<{ Variables: { user: AuthUser } }>();
|
||||
|
||||
app.onError(errorHandler);
|
||||
app.use(
|
||||
|
|
@ -38,10 +44,21 @@ app.use(
|
|||
// 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();
|
||||
// Versioned API surface — additive-only changes within v1, breaking
|
||||
// changes go to /v2 (MARKETPLACE_PLAN §3 architecture principle 1).
|
||||
const v1 = new Hono<{ Variables: { user: AuthUser } }>();
|
||||
|
||||
// Public reads on author + deck profiles allow anonymous access; the
|
||||
// mutating endpoints in the same routers gate themselves by checking
|
||||
// for `c.get('user')`. Until we have that anonymous-aware middleware
|
||||
// (Phase γ adds optionalAuth), every /v1 route gates on JWT — public
|
||||
// reads still work for any signed-in user, which covers the only
|
||||
// surface we have right now (author dashboard + deck CRUD).
|
||||
v1.use('/*', jwtAuth(config.manaAuthUrl));
|
||||
|
||||
v1.route('/authors', createAuthorRoutes(authorService));
|
||||
v1.route('/decks', createDeckRoutes(authorService, deckService));
|
||||
|
||||
v1.get('/', (c) =>
|
||||
c.json({
|
||||
service: 'cards-server',
|
||||
|
|
|
|||
132
services/cards-server/src/lib/ai-moderation.ts
Normal file
132
services/cards-server/src/lib/ai-moderation.ts
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
/**
|
||||
* AI moderation first-pass via mana-llm.
|
||||
*
|
||||
* Asks the model to classify a deck's content into one of three
|
||||
* verdicts: pass, flag, block. `flag` means a human reviewer should
|
||||
* look at it before the deck goes public; `block` means refuse the
|
||||
* publish outright.
|
||||
*
|
||||
* Per MARKETPLACE_PLAN principle 6: "AI is moderator, not gatekeeper"
|
||||
* — `block` is only used for unambiguous offences (CSAM, real-world
|
||||
* doxxing). Anything ambiguous flows to human review.
|
||||
*
|
||||
* Fail-open: if mana-llm is unreachable or returns malformed JSON,
|
||||
* the verdict defaults to `flag` so the human reviewer catches it.
|
||||
* Better a slow publish than a quietly-skipped check.
|
||||
*/
|
||||
|
||||
const SYSTEM_PROMPT = `Du bist Inhalts-Moderator für eine Karteikarten-Plattform. Bewerte den vorgelegten Inhalt nach folgenden Kategorien:
|
||||
|
||||
- spam: Werbe-Spam, ohne erkennbaren Lerninhalt
|
||||
- copyright: offensichtliche, lange Lehrbuch-Auszüge ohne Quelle/Lizenzhinweis
|
||||
- nsfw: sexuell explizit, jugendgefährdend
|
||||
- misinformation: nachweislich falsche Fakten als Tatsachen präsentiert (außerhalb subjektiver Themen)
|
||||
- hate: Hassrede, Diskriminierung gegen geschützte Gruppen
|
||||
- csam: Material, das Minderjährige sexualisiert (führt IMMER zu block)
|
||||
|
||||
Antworte AUSSCHLIESSLICH mit einem JSON-Objekt:
|
||||
{"verdict":"pass|flag|block","categories":["..."],"rationale":"kurze Begründung"}
|
||||
|
||||
Regeln:
|
||||
- pass: keine Kategorien getroffen
|
||||
- flag: eine oder mehrere Kategorien außer csam
|
||||
- block: csam ODER unmissverständliche Kombination aus mehreren schweren Kategorien
|
||||
- Im Zweifel: flag (nicht block) — eine menschliche Moderatorin entscheidet final.`;
|
||||
|
||||
export interface ModerationVerdict {
|
||||
verdict: 'pass' | 'flag' | 'block';
|
||||
categories: string[];
|
||||
rationale: string;
|
||||
model: string;
|
||||
}
|
||||
|
||||
export interface ModerationInput {
|
||||
title: string;
|
||||
description?: string;
|
||||
cards: { fields: Record<string, string> }[];
|
||||
}
|
||||
|
||||
const MODEL = process.env.AI_MODERATION_MODEL || 'gpt-4o-mini';
|
||||
const MAX_CARDS_FOR_PROMPT = 50;
|
||||
|
||||
function buildPrompt(input: ModerationInput): string {
|
||||
const sample = input.cards.slice(0, MAX_CARDS_FOR_PROMPT).map((c, i) => {
|
||||
const fieldsStr = Object.entries(c.fields)
|
||||
.map(([k, v]) => ` ${k}: ${v}`)
|
||||
.join('\n');
|
||||
return `Karte ${i + 1}:\n${fieldsStr}`;
|
||||
});
|
||||
return [
|
||||
`Deck-Titel: ${input.title}`,
|
||||
input.description ? `Beschreibung: ${input.description}` : '',
|
||||
`Karten (${input.cards.length} insgesamt, erste ${sample.length} gezeigt):`,
|
||||
...sample,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n\n');
|
||||
}
|
||||
|
||||
function failOpen(rationale: string): ModerationVerdict {
|
||||
return {
|
||||
verdict: 'flag',
|
||||
categories: ['_internal'],
|
||||
rationale: `AI-Mod fail-open: ${rationale}`,
|
||||
model: MODEL,
|
||||
};
|
||||
}
|
||||
|
||||
function stripCodeFences(s: string): string {
|
||||
return s
|
||||
.replace(/^\s*```(?:json)?\s*/i, '')
|
||||
.replace(/\s*```\s*$/i, '')
|
||||
.trim();
|
||||
}
|
||||
|
||||
export async function moderateDeckContent(
|
||||
input: ModerationInput,
|
||||
llmUrl: string
|
||||
): Promise<ModerationVerdict> {
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(`${llmUrl}/v1/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
model: MODEL,
|
||||
temperature: 0,
|
||||
messages: [
|
||||
{ role: 'system', content: SYSTEM_PROMPT },
|
||||
{ role: 'user', content: buildPrompt(input) },
|
||||
],
|
||||
}),
|
||||
});
|
||||
} catch (e) {
|
||||
return failOpen(`network: ${(e as Error).message}`);
|
||||
}
|
||||
|
||||
if (!res.ok) return failOpen(`http ${res.status}`);
|
||||
|
||||
const json = (await res.json().catch(() => null)) as {
|
||||
choices?: { message?: { content?: string } }[];
|
||||
} | null;
|
||||
const raw = json?.choices?.[0]?.message?.content?.trim();
|
||||
if (!raw) return failOpen('empty response');
|
||||
|
||||
let parsed: { verdict?: unknown; categories?: unknown; rationale?: unknown };
|
||||
try {
|
||||
parsed = JSON.parse(stripCodeFences(raw));
|
||||
} catch {
|
||||
return failOpen('invalid JSON');
|
||||
}
|
||||
|
||||
const verdict =
|
||||
parsed.verdict === 'pass' || parsed.verdict === 'flag' || parsed.verdict === 'block'
|
||||
? parsed.verdict
|
||||
: 'flag';
|
||||
const categories = Array.isArray(parsed.categories)
|
||||
? parsed.categories.filter((c): c is string => typeof c === 'string')
|
||||
: [];
|
||||
const rationale = typeof parsed.rationale === 'string' ? parsed.rationale : '';
|
||||
|
||||
return { verdict, categories, rationale, model: MODEL };
|
||||
}
|
||||
44
services/cards-server/src/lib/hash.ts
Normal file
44
services/cards-server/src/lib/hash.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
/**
|
||||
* Content hashing — SHA-256 over canonicalized payloads. Drives:
|
||||
* - per-card `content_hash` (smart-merge across version bumps)
|
||||
* - per-version `content_hash` (cache + dedup detection)
|
||||
*
|
||||
* Canonicalization sorts object keys recursively so `{a:1,b:2}` and
|
||||
* `{b:2,a:1}` produce identical hashes. Without that, equivalent
|
||||
* payloads from different clients would diverge. Numbers/booleans
|
||||
* stringify naturally; strings are passed through verbatim.
|
||||
*/
|
||||
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
function canonical(value: unknown): unknown {
|
||||
if (value === null || typeof value !== 'object') return value;
|
||||
if (Array.isArray(value)) return value.map(canonical);
|
||||
const sorted: Record<string, unknown> = {};
|
||||
for (const key of Object.keys(value as Record<string, unknown>).sort()) {
|
||||
sorted[key] = canonical((value as Record<string, unknown>)[key]);
|
||||
}
|
||||
return sorted;
|
||||
}
|
||||
|
||||
function sha256(input: string): string {
|
||||
return createHash('sha256').update(input).digest('hex');
|
||||
}
|
||||
|
||||
/** Hash for a single card — based on (type, fields). */
|
||||
export function hashCard(card: { type: string; fields: Record<string, string> }): string {
|
||||
return sha256(JSON.stringify(canonical({ type: card.type, fields: card.fields })));
|
||||
}
|
||||
|
||||
/**
|
||||
* Hash for an ordered list of cards — version content hash. Order
|
||||
* matters because re-ordering is a meaningful change for the learner.
|
||||
*/
|
||||
export function hashVersionCards(
|
||||
cards: { type: string; fields: Record<string, string>; ord: number }[]
|
||||
): string {
|
||||
const ordered = [...cards].sort((a, b) => a.ord - b.ord);
|
||||
return sha256(
|
||||
JSON.stringify(ordered.map((c) => canonical({ type: c.type, fields: c.fields, ord: c.ord })))
|
||||
);
|
||||
}
|
||||
58
services/cards-server/src/lib/slug.ts
Normal file
58
services/cards-server/src/lib/slug.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
/**
|
||||
* URL-safe slug helpers.
|
||||
*
|
||||
* `slugify` is best-effort — turns "Anna Lang!" into "anna-lang" — for
|
||||
* suggesting an initial slug. `validateSlug` is strict and what we
|
||||
* enforce on every write so the URL space stays predictable.
|
||||
*/
|
||||
|
||||
const MAX_SLUG_LEN = 60;
|
||||
const MIN_SLUG_LEN = 3;
|
||||
|
||||
const SLUG_RE = /^[a-z0-9](?:[a-z0-9-]{1,58}[a-z0-9])?$/;
|
||||
|
||||
const RESERVED_SLUGS = new Set([
|
||||
'admin',
|
||||
'api',
|
||||
'app',
|
||||
'auth',
|
||||
'docs',
|
||||
'explore',
|
||||
'feed',
|
||||
'help',
|
||||
'me',
|
||||
'mana',
|
||||
'new',
|
||||
'public',
|
||||
'search',
|
||||
'settings',
|
||||
'support',
|
||||
'system',
|
||||
'u',
|
||||
'd',
|
||||
'v1',
|
||||
'v2',
|
||||
]);
|
||||
|
||||
export function slugify(input: string): string {
|
||||
return input
|
||||
.normalize('NFKD')
|
||||
.replace(/[̀-ͯ]/g, '') // strip diacritics
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, MAX_SLUG_LEN);
|
||||
}
|
||||
|
||||
export interface SlugValidation {
|
||||
ok: boolean;
|
||||
reason?: 'too-short' | 'too-long' | 'invalid-chars' | 'reserved';
|
||||
}
|
||||
|
||||
export function validateSlug(slug: string): SlugValidation {
|
||||
if (slug.length < MIN_SLUG_LEN) return { ok: false, reason: 'too-short' };
|
||||
if (slug.length > MAX_SLUG_LEN) return { ok: false, reason: 'too-long' };
|
||||
if (!SLUG_RE.test(slug)) return { ok: false, reason: 'invalid-chars' };
|
||||
if (RESERVED_SLUGS.has(slug)) return { ok: false, reason: 'reserved' };
|
||||
return { ok: true };
|
||||
}
|
||||
38
services/cards-server/src/routes/authors.ts
Normal file
38
services/cards-server/src/routes/authors.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import { Hono } from 'hono';
|
||||
import { z } from 'zod';
|
||||
import type { AuthUser } from '../middleware/jwt-auth';
|
||||
import type { AuthorService } from '../services/authors';
|
||||
import { BadRequestError } from '../lib/errors';
|
||||
|
||||
const upsertSchema = z.object({
|
||||
slug: z.string(),
|
||||
displayName: z.string().min(1).max(80),
|
||||
bio: z.string().max(500).optional(),
|
||||
avatarUrl: z.string().url().max(512).optional(),
|
||||
pseudonym: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export function createAuthorRoutes(authorService: AuthorService) {
|
||||
const router = new Hono<{ Variables: { user: AuthUser } }>();
|
||||
|
||||
router.post('/me', async (c) => {
|
||||
const user = c.get('user');
|
||||
const parsed = upsertSchema.safeParse(await c.req.json().catch(() => ({})));
|
||||
if (!parsed.success) throw new BadRequestError('Invalid body', parsed.error.format());
|
||||
const author = await authorService.upsertMe(user.userId, parsed.data);
|
||||
return c.json(author);
|
||||
});
|
||||
|
||||
router.get('/me', async (c) => {
|
||||
const user = c.get('user');
|
||||
const author = await authorService.getByUserId(user.userId);
|
||||
return c.json(author);
|
||||
});
|
||||
|
||||
router.get('/:slug', async (c) => {
|
||||
const author = await authorService.getPublicBySlug(c.req.param('slug'));
|
||||
return c.json(author);
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
68
services/cards-server/src/routes/decks.ts
Normal file
68
services/cards-server/src/routes/decks.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import { Hono } from 'hono';
|
||||
import { z } from 'zod';
|
||||
import type { AuthUser } from '../middleware/jwt-auth';
|
||||
import type { AuthorService } from '../services/authors';
|
||||
import type { DeckService } from '../services/decks';
|
||||
import { BadRequestError } from '../lib/errors';
|
||||
|
||||
const cardTypes = [
|
||||
'basic',
|
||||
'basic-reverse',
|
||||
'cloze',
|
||||
'type-in',
|
||||
'image-occlusion',
|
||||
'audio',
|
||||
'multiple-choice',
|
||||
] as const;
|
||||
|
||||
const initSchema = z.object({
|
||||
slug: z.string(),
|
||||
title: z.string().min(1).max(140),
|
||||
description: z.string().max(2000).optional(),
|
||||
language: z.string().min(2).max(8).optional(),
|
||||
license: z.string().max(64).optional(),
|
||||
priceCredits: z.number().int().min(0).max(10_000).optional(),
|
||||
});
|
||||
|
||||
const publishSchema = z.object({
|
||||
semver: z.string(),
|
||||
changelog: z.string().max(2000).optional(),
|
||||
cards: z
|
||||
.array(
|
||||
z.object({
|
||||
type: z.enum(cardTypes),
|
||||
fields: z.record(z.string(), z.string()),
|
||||
})
|
||||
)
|
||||
.min(1)
|
||||
.max(5_000),
|
||||
});
|
||||
|
||||
export function createDeckRoutes(authorService: AuthorService, deckService: DeckService) {
|
||||
const router = new Hono<{ Variables: { user: AuthUser } }>();
|
||||
|
||||
router.post('/', async (c) => {
|
||||
const user = c.get('user');
|
||||
await authorService.assertNotBanned(user.userId);
|
||||
const parsed = initSchema.safeParse(await c.req.json().catch(() => ({})));
|
||||
if (!parsed.success) throw new BadRequestError('Invalid body', parsed.error.format());
|
||||
const deck = await deckService.init(user.userId, parsed.data);
|
||||
return c.json(deck, 201);
|
||||
});
|
||||
|
||||
router.get('/:slug', async (c) => {
|
||||
const result = await deckService.getBySlug(c.req.param('slug'));
|
||||
return c.json(result);
|
||||
});
|
||||
|
||||
router.post('/:slug/publish', async (c) => {
|
||||
const user = c.get('user');
|
||||
await authorService.assertNotBanned(user.userId);
|
||||
const parsed = publishSchema.safeParse(await c.req.json().catch(() => ({})));
|
||||
if (!parsed.success) throw new BadRequestError('Invalid body', parsed.error.format());
|
||||
const result = await deckService.publish(user.userId, c.req.param('slug'), parsed.data);
|
||||
return c.json(result, 201);
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
104
services/cards-server/src/services/authors.ts
Normal file
104
services/cards-server/src/services/authors.ts
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
/**
|
||||
* Author service — CRUD on `cards.authors` plus the lookups the
|
||||
* routes need (by-slug, by-userId).
|
||||
*
|
||||
* Slug is unique per author. We don't auto-suggest slugs server-side;
|
||||
* the client picks one and we validate. If a user changes their slug,
|
||||
* the old slug isn't preserved (no redirects yet — Phase η maybe).
|
||||
*/
|
||||
|
||||
import { eq } from 'drizzle-orm';
|
||||
import type { Database } from '../db/connection';
|
||||
import { authors } from '../db/schema';
|
||||
import { validateSlug } from '../lib/slug';
|
||||
import { BadRequestError, ConflictError, NotFoundError } from '../lib/errors';
|
||||
|
||||
export interface AuthorInput {
|
||||
slug: string;
|
||||
displayName: string;
|
||||
bio?: string;
|
||||
avatarUrl?: string;
|
||||
pseudonym?: boolean;
|
||||
}
|
||||
|
||||
export class AuthorService {
|
||||
constructor(private readonly db: Database) {}
|
||||
|
||||
async upsertMe(userId: string, input: AuthorInput) {
|
||||
const validation = validateSlug(input.slug);
|
||||
if (!validation.ok) {
|
||||
throw new BadRequestError(`Slug invalid: ${validation.reason}`);
|
||||
}
|
||||
|
||||
// Slug must be free or already owned by us.
|
||||
const existingBySlug = await this.db.query.authors.findFirst({
|
||||
where: eq(authors.slug, input.slug),
|
||||
});
|
||||
if (existingBySlug && existingBySlug.userId !== userId) {
|
||||
throw new ConflictError('Slug already taken');
|
||||
}
|
||||
|
||||
const existing = await this.db.query.authors.findFirst({
|
||||
where: eq(authors.userId, userId),
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
const [updated] = await this.db
|
||||
.update(authors)
|
||||
.set({
|
||||
slug: input.slug,
|
||||
displayName: input.displayName,
|
||||
bio: input.bio,
|
||||
avatarUrl: input.avatarUrl,
|
||||
pseudonym: input.pseudonym ?? existing.pseudonym,
|
||||
})
|
||||
.where(eq(authors.userId, userId))
|
||||
.returning();
|
||||
return updated;
|
||||
}
|
||||
|
||||
const [created] = await this.db
|
||||
.insert(authors)
|
||||
.values({
|
||||
userId,
|
||||
slug: input.slug,
|
||||
displayName: input.displayName,
|
||||
bio: input.bio,
|
||||
avatarUrl: input.avatarUrl,
|
||||
pseudonym: input.pseudonym ?? false,
|
||||
})
|
||||
.returning();
|
||||
return created;
|
||||
}
|
||||
|
||||
async getByUserId(userId: string) {
|
||||
const row = await this.db.query.authors.findFirst({ where: eq(authors.userId, userId) });
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
/** Public profile lookup — strips bannedReason etc. */
|
||||
async getPublicBySlug(slug: string) {
|
||||
const row = await this.db.query.authors.findFirst({ where: eq(authors.slug, slug) });
|
||||
if (!row) throw new NotFoundError('Author not found');
|
||||
return {
|
||||
slug: row.slug,
|
||||
displayName: row.displayName,
|
||||
bio: row.bio,
|
||||
avatarUrl: row.avatarUrl,
|
||||
joinedAt: row.joinedAt,
|
||||
pseudonym: row.pseudonym,
|
||||
verifiedMana: row.verifiedMana,
|
||||
verifiedCommunity: row.verifiedCommunity,
|
||||
banned: row.bannedAt !== null,
|
||||
};
|
||||
}
|
||||
|
||||
async assertNotBanned(userId: string) {
|
||||
const row = await this.getByUserId(userId);
|
||||
if (!row) throw new BadRequestError('You need an author profile first (POST /v1/authors/me).');
|
||||
if (row.bannedAt) {
|
||||
throw new BadRequestError(`Author banned: ${row.bannedReason ?? 'no reason given'}`);
|
||||
}
|
||||
return row;
|
||||
}
|
||||
}
|
||||
223
services/cards-server/src/services/decks.ts
Normal file
223
services/cards-server/src/services/decks.ts
Normal file
|
|
@ -0,0 +1,223 @@
|
|||
/**
|
||||
* Deck service — init + publish.
|
||||
*
|
||||
* `init` claims a slug and creates a `cards.decks` row with no
|
||||
* version yet (so authors can fiddle with metadata before their first
|
||||
* publish). `publish` runs the AI-mod first-pass, computes per-card
|
||||
* + per-version content hashes, writes a new immutable version + its
|
||||
* cards, and atomically updates `latest_version_id` on the deck.
|
||||
*
|
||||
* Per MARKETPLACE_PLAN: a `block` verdict from AI mod refuses the
|
||||
* publish outright. A `flag` verdict still publishes (so the deck
|
||||
* isn't blocked on slow human review) but writes a row into
|
||||
* `ai_moderation_log` so the moderation inbox surfaces it.
|
||||
*/
|
||||
|
||||
import { and, eq, sql } from 'drizzle-orm';
|
||||
import type { Database } from '../db/connection';
|
||||
import { publicDecks, publicDeckVersions, publicDeckCards, aiModerationLog } from '../db/schema';
|
||||
import { validateSlug } from '../lib/slug';
|
||||
import { hashCard, hashVersionCards } from '../lib/hash';
|
||||
import { moderateDeckContent } from '../lib/ai-moderation';
|
||||
import { BadRequestError, ConflictError, ForbiddenError, NotFoundError } from '../lib/errors';
|
||||
|
||||
export interface InitDeckInput {
|
||||
slug: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
language?: string;
|
||||
license?: string;
|
||||
priceCredits?: number;
|
||||
}
|
||||
|
||||
export interface PublishInput {
|
||||
semver: string;
|
||||
changelog?: string;
|
||||
cards: {
|
||||
type:
|
||||
| 'basic'
|
||||
| 'basic-reverse'
|
||||
| 'cloze'
|
||||
| 'type-in'
|
||||
| 'image-occlusion'
|
||||
| 'audio'
|
||||
| 'multiple-choice';
|
||||
fields: Record<string, string>;
|
||||
}[];
|
||||
}
|
||||
|
||||
export interface PublishResult {
|
||||
deck: typeof publicDecks.$inferSelect;
|
||||
version: typeof publicDeckVersions.$inferSelect;
|
||||
moderation: { verdict: 'pass' | 'flag' | 'block'; categories: string[] };
|
||||
}
|
||||
|
||||
const SEMVER_RE = /^(\d+)\.(\d+)\.(\d+)$/;
|
||||
|
||||
function validatePrice(price: number, license: string) {
|
||||
if (price < 0) throw new BadRequestError('priceCredits cannot be negative');
|
||||
if (price > 0 && license !== 'Cards-Pro-Only-1.0') {
|
||||
throw new BadRequestError('Paid decks must use the Cards-Pro-Only-1.0 license');
|
||||
}
|
||||
}
|
||||
|
||||
export class DeckService {
|
||||
constructor(
|
||||
private readonly db: Database,
|
||||
private readonly llmUrl: string
|
||||
) {}
|
||||
|
||||
async init(ownerUserId: string, input: InitDeckInput) {
|
||||
const validation = validateSlug(input.slug);
|
||||
if (!validation.ok) throw new BadRequestError(`Slug invalid: ${validation.reason}`);
|
||||
|
||||
const license = input.license ?? 'Cards-Personal-Use-1.0';
|
||||
const priceCredits = input.priceCredits ?? 0;
|
||||
validatePrice(priceCredits, license);
|
||||
|
||||
const existing = await this.db.query.publicDecks.findFirst({
|
||||
where: eq(publicDecks.slug, input.slug),
|
||||
});
|
||||
if (existing) throw new ConflictError('Slug already taken');
|
||||
|
||||
const [created] = await this.db
|
||||
.insert(publicDecks)
|
||||
.values({
|
||||
slug: input.slug,
|
||||
title: input.title,
|
||||
description: input.description,
|
||||
language: input.language,
|
||||
license,
|
||||
priceCredits,
|
||||
ownerUserId,
|
||||
})
|
||||
.returning();
|
||||
return created;
|
||||
}
|
||||
|
||||
async getBySlug(slug: string) {
|
||||
const deck = await this.db.query.publicDecks.findFirst({
|
||||
where: eq(publicDecks.slug, slug),
|
||||
});
|
||||
if (!deck) throw new NotFoundError('Deck not found');
|
||||
|
||||
const version = deck.latestVersionId
|
||||
? await this.db.query.publicDeckVersions.findFirst({
|
||||
where: eq(publicDeckVersions.id, deck.latestVersionId),
|
||||
})
|
||||
: null;
|
||||
|
||||
return { deck, latestVersion: version };
|
||||
}
|
||||
|
||||
async publish(ownerUserId: string, slug: string, input: PublishInput): Promise<PublishResult> {
|
||||
if (!SEMVER_RE.test(input.semver)) {
|
||||
throw new BadRequestError('semver must look like 1.0.0');
|
||||
}
|
||||
if (input.cards.length === 0) {
|
||||
throw new BadRequestError('A version needs at least one card');
|
||||
}
|
||||
|
||||
const deck = await this.db.query.publicDecks.findFirst({
|
||||
where: eq(publicDecks.slug, slug),
|
||||
});
|
||||
if (!deck) throw new NotFoundError('Deck not found');
|
||||
if (deck.ownerUserId !== ownerUserId) {
|
||||
throw new ForbiddenError('Only the deck owner can publish');
|
||||
}
|
||||
if (deck.isTakedown) throw new ForbiddenError('Deck is under takedown');
|
||||
|
||||
// semver must be strictly greater than the latest published
|
||||
// version so version history stays linear.
|
||||
if (deck.latestVersionId) {
|
||||
const latest = await this.db.query.publicDeckVersions.findFirst({
|
||||
where: eq(publicDeckVersions.id, deck.latestVersionId),
|
||||
});
|
||||
if (latest && !semverGreater(input.semver, latest.semver)) {
|
||||
throw new ConflictError(`semver ${input.semver} must be > ${latest.semver}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 1) AI moderation first-pass.
|
||||
const moderation = await moderateDeckContent(
|
||||
{
|
||||
title: deck.title,
|
||||
description: deck.description ?? undefined,
|
||||
cards: input.cards.map((c) => ({ fields: c.fields })),
|
||||
},
|
||||
this.llmUrl
|
||||
);
|
||||
if (moderation.verdict === 'block') {
|
||||
throw new ForbiddenError(
|
||||
`Refused by content moderation: ${moderation.rationale || 'no rationale'}`
|
||||
);
|
||||
}
|
||||
|
||||
// 2) Compute hashes.
|
||||
const cardsWithOrd = input.cards.map((c, i) => ({ ...c, ord: i }));
|
||||
const versionContentHash = hashVersionCards(cardsWithOrd);
|
||||
|
||||
// 3) Insert version + cards + flip latest_version_id atomically.
|
||||
const result = await this.db.transaction(async (tx) => {
|
||||
const [version] = await tx
|
||||
.insert(publicDeckVersions)
|
||||
.values({
|
||||
deckId: deck.id,
|
||||
semver: input.semver,
|
||||
changelog: input.changelog,
|
||||
contentHash: versionContentHash,
|
||||
cardCount: cardsWithOrd.length,
|
||||
})
|
||||
.returning();
|
||||
|
||||
await tx.insert(publicDeckCards).values(
|
||||
cardsWithOrd.map((c) => ({
|
||||
versionId: version.id,
|
||||
type: c.type,
|
||||
fields: c.fields,
|
||||
ord: c.ord,
|
||||
contentHash: hashCard(c),
|
||||
}))
|
||||
);
|
||||
|
||||
await tx.insert(aiModerationLog).values({
|
||||
versionId: version.id,
|
||||
verdict: moderation.verdict,
|
||||
categories: moderation.categories,
|
||||
model: moderation.model,
|
||||
rationale: moderation.rationale,
|
||||
});
|
||||
|
||||
const [updatedDeck] = await tx
|
||||
.update(publicDecks)
|
||||
.set({ latestVersionId: version.id })
|
||||
.where(and(eq(publicDecks.id, deck.id)))
|
||||
.returning();
|
||||
|
||||
return { deck: updatedDeck, version };
|
||||
});
|
||||
|
||||
return {
|
||||
deck: result.deck,
|
||||
version: result.version,
|
||||
moderation: { verdict: moderation.verdict, categories: moderation.categories },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function semverGreater(a: string, b: string): boolean {
|
||||
const matchA = a.match(SEMVER_RE);
|
||||
const matchB = b.match(SEMVER_RE);
|
||||
if (!matchA || !matchB) return false;
|
||||
for (let i = 1; i <= 3; i++) {
|
||||
const da = Number.parseInt(matchA[i], 10);
|
||||
const db = Number.parseInt(matchB[i], 10);
|
||||
if (da > db) return true;
|
||||
if (da < db) return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Silence unused-binding lint for `sql` import — we keep it ready for
|
||||
// upcoming routes (server-side orderings / counts).
|
||||
void sql;
|
||||
Loading…
Add table
Add a link
Reference in a new issue