/** * Decks-API — event-sourced gegen `@mana/event-sync`. * * Migration L-1f (2026-05-20): User-owned Reads + Writes laufen jetzt * lokal über die IndexedDB-Projektion. Server-AI-Endpoints * (generate, generate-from-image, distractors) bleiben HTTP — sie * konsumieren Tier-gated mana-credits und returnen JSON, das der Client * dann als Events emittiert. Marketplace-Reads bleiben ebenfalls HTTP * (cross-user, kein eigener event-stream pro User). */ import type { Deck, DeckCreate, DeckUpdate } from '@wordeck/domain'; import type { WordeckDeckState as DeckProjection } from '@mana/shared-schemas'; import { ulid } from 'ulid'; import { api, apiForm } from './client.ts'; import { deckAggregateId, deckStateToRow } from './event-adapters.ts'; import { emitEvent } from './event-builder.ts'; import { getSync } from '../sync.svelte.ts'; export async function listDecks( opts: { forkedFromMarketplace?: boolean; archived?: boolean } = {}, ): Promise<{ decks: Deck[]; total: number }> { const sync = await getSync(); const all = await sync.aggregateList('deck'); const decks = all .filter(({ state }) => state.deletedAt === null) .filter(({ state }) => opts.archived ? state.archivedAt !== null : state.archivedAt === null, ) .filter(({ state }) => opts.forkedFromMarketplace ? state.forkedFromMarketplaceDeckId !== null : true, ) .map(({ aggregateId, state }) => deckStateToRow(aggregateId, state)); return { decks, total: decks.length }; } export async function getDeck(id: string): Promise { const sync = await getSync(); const state = await sync.aggregateState(deckAggregateId(id)); if (!state || state.deletedAt) { throw new Error(`deck ${id} nicht gefunden`); } return deckStateToRow(deckAggregateId(id), state); } export async function createDeck(input: DeckCreate): Promise { const deckId = ulid(); const aggregateId = deckAggregateId(deckId); await emitEvent(aggregateId, 'DeckCreated', { deckId, name: input.name, description: input.description ?? null, color: input.color ?? null, category: input.category ?? null, }); // Optionale Init-Settings hinterherschicken if (input.fsrs_settings && Object.keys(input.fsrs_settings).length > 0) { await emitEvent(aggregateId, 'DeckFsrsSettingsUpdated', { deckId, newSettingsJson: JSON.stringify(input.fsrs_settings), }); } return getDeck(deckId); } export async function updateDeck(id: string, patch: DeckUpdate): Promise { const aggregateId = deckAggregateId(id); if (patch.name !== undefined) { await emitEvent(aggregateId, 'DeckRenamed', { deckId: id, newName: patch.name }); } if (patch.description !== undefined) { await emitEvent(aggregateId, 'DeckDescriptionUpdated', { deckId: id, newDescription: patch.description ?? null, }); } if (patch.color !== undefined) { await emitEvent(aggregateId, 'DeckColorChanged', { deckId: id, newColor: patch.color ?? null, }); } if (patch.category !== undefined) { await emitEvent(aggregateId, 'DeckCategoryChanged', { deckId: id, newCategory: patch.category ?? null, }); } if (patch.fsrs_settings !== undefined) { await emitEvent(aggregateId, 'DeckFsrsSettingsUpdated', { deckId: id, newSettingsJson: JSON.stringify(patch.fsrs_settings), }); } if (patch.archived !== undefined) { await emitEvent( aggregateId, patch.archived ? 'DeckArchived' : 'DeckUnarchived', { deckId: id }, ); } if (patch.visibility !== undefined && patch.visibility !== 'private') { await emitEvent(aggregateId, 'DeckPublished', { deckId: id, visibility: patch.visibility, license: 'CC-BY-4.0', }); } return getDeck(id); } export async function archiveDeck(id: string): Promise { return updateDeck(id, { archived: true }); } export async function unarchiveDeck(id: string): Promise { return updateDeck(id, { archived: false }); } export async function deleteDeck(id: string): Promise<{ deleted: string }> { await emitEvent(deckAggregateId(id), 'DeckDeleted', { deckId: id }); return { deleted: id }; } /** * Marketplace-Source-Lookup bleibt HTTP — Marketplace ist cross-user und * lebt im Server-pgSchema `marketplace.*`, nicht im Per-User-Event-Stream. */ export function getMarketplaceSource(id: string) { return api<{ slug: string } | null>(`/api/v1/decks/${id}/marketplace-source`); } /** * duplicateDeck bleibt vorerst HTTP — der Server kopiert Karten in * einer Transaktion. Sobald Cards-API auf events lebt, kann das * client-side als Event-Burst erfolgen. */ export function duplicateDeck(id: string) { return api(`/api/v1/decks/${id}/duplicate`, { method: 'POST' }); } /** * AI-Generate-Endpoints bleiben HTTP — Tier-gated über mana-credits, * Server hat Prompt-Templates + LLM-Provider-Routing. Result wird heute * direkt als Deck+Cards persistiert vom Server. Follow-up-Task: Result * als Event-Burst zum Client schicken, Client emittet lokal. */ export function generateDeck(input: { prompt: string; language?: string; count?: number; url?: string; }) { return api<{ deck: Deck; cards_created: number }>('/api/v1/decks/generate', { method: 'POST', body: input, }); } export function fetchDistractors( deckId: string, opts: { cardId?: string; count?: number; field?: string } = {}, ) { const params = new URLSearchParams(); if (opts.cardId) params.set('card_id', opts.cardId); if (opts.count) params.set('count', String(opts.count)); if (opts.field) params.set('field', opts.field); const qs = params.size ? `?${params}` : ''; return api<{ distractors: string[] }>(`/api/v1/decks/${deckId}/distractors${qs}`); } export function generateDeckFromImage( files: File | File[], opts: { language?: string; count?: number; url?: string }, ) { const arr = Array.isArray(files) ? files : [files]; if (arr.length === 0) { return api<{ deck: Deck; cards_created: number }>('/api/v1/decks/from-image', { method: 'POST', body: { language: opts.language, count: opts.count, url: opts.url }, }); } const form = new FormData(); for (const f of arr) form.append('file', f); if (opts.language) form.append('language', opts.language); if (opts.count != null) form.append('count', String(opts.count)); if (opts.url) form.append('url', opts.url); return apiForm<{ deck: Deck; cards_created: number }>('/api/v1/decks/from-image', form); }