wordeck/apps/web/src/lib/api/decks.ts
Till JS 375a6af86e
Some checks are pending
CI / validate (push) Waiting to run
feat(wordeck): Big-Bang-Cutover L-2 — Server-CRUD raus, alles auf event-sync
L-2 von mana/docs/playbooks/LOCAL_FIRST_LOGIN_OPTIONAL.md:

apps/api:
- routes/decks.ts: CRUD-Routes gelöscht. Verbleibend nur Read-Only:
  GET /:id/marketplace-source + GET /:deckId/distractors
- routes/cards.ts: alle CRUD-Routes → 410 Gone mit Deprecation/Sunset
  Header (Sunset 2026-06-20). Sub-Pfade weren von Hono auf das *-Handler
  geleitet, die alle 410 zurückgeben
- routes/reviews.ts: alle Routes → 410 Gone, FSRS-Compute ist jetzt
  client-side via @wordeck/domain.gradeReview
- routes/decks-generate.ts: returnt nur noch LLM-Vorschlag
  ({ suggestion: { deck, cards } }), Server schreibt NICHTS mehr in
  decks/cards/reviews. Client emittet Events lokal in event-sync
- routes/dsgvo.ts: Doku-Block: nach Big-Bang sind neue User-Daten in
  sync2 mana_sync_v2.wordeck.*, nicht mehr hier. mana-admin-Fanout
  muss beide Quellen abfragen
- scripts/migrate-db-to-events.ts: Stub für DB→Event-Sync-Migration.
  Idempotent via idempotencyKey='migration:<row-id>:<event-type>'.
  Plaintext-Migration (kein User-Master-Key zur Migration-Zeit),
  --dry-run/--commit, --user-id-Filter. mintToken() noch als Stub
  (braucht Service-Key-basierten JWT-Mint in mana-auth)

apps/web:
- lib/api/decks.ts: generateDeck wrapped jetzt den Server-Vorschlag
  via lokales createDeck + createCard-Burst. UI sieht weiterhin
  { deck, cards_created } als Return-Shape

apps/api/tests:
- decks.test.ts: post-cutover-Smokes (Auth-Check + 404 für entfernte
  Routes)
- cards.test.ts: 410-Gone-Verification mit Deprecation-Header
- reviews.test.ts: 410-Gone-Verification

Type-check 0 Errors. Test-Suite: pre-existing fails (dsgvo, share,
tools — alle pre-cutover schon rot, Rebrand-Drift cards→wordeck);
meine drei Big-Bang-Tests-Files 7/7 grün.

Offene Punkte (bewusst geflaggt, nicht Big-Bang-Block):
- DSGVO-Pfad cross-source-Aggregation (sync2 + DB) ist mana-admin's
  Architektur-Job, nicht wordeck-app
- Migration-Script mintToken() braucht mana-auth-Service-Key-Pfad
  oder sync2-Service-Key-Auth-Mode (Plattform-Arbeit)
- Live-User-Migration: Skript-Stub ist ungetestet, muss vor echtem
  Run code-reviewed + 1-2 User-Dry-Runs

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 21:51:22 +02:00

208 lines
6.8 KiB
TypeScript

/**
* 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<DeckProjection>('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<Deck> {
const sync = await getSync();
const state = await sync.aggregateState<DeckProjection>(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<Deck> {
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<Deck> {
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<Deck> {
return updateDeck(id, { archived: true });
}
export async function unarchiveDeck(id: string): Promise<Deck> {
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<Deck>(`/api/v1/decks/${id}/duplicate`, { method: 'POST' });
}
/**
* AI-Generate-Endpoint nach Big-Bang-Cutover (L-2): Server liefert nur
* den LLM-Vorschlag (Deck-Metadaten + Karten-Inhalte), der Client
* emittet die Events lokal in event-sync. Tier-Gating + Rate-Limit
* bleibt server-seitig (LLM-Aufruf ist teuer).
*/
export async function generateDeck(input: {
prompt: string;
language?: string;
count?: number;
url?: string;
}): Promise<{ deck: Deck; cards_created: number }> {
const res = await api<{
suggestion: {
deck: { name: string; description: string; color: string };
cards: Array<{ type: 'basic'; fields: { front: string; back: string } }>;
};
}>('/api/v1/decks/generate', { method: 'POST', body: input });
const created = await createDeck({
name: res.suggestion.deck.name,
description: res.suggestion.deck.description,
color: res.suggestion.deck.color,
});
const { createCard } = await import('./cards.ts');
for (const card of res.suggestion.cards) {
await createCard({
deck_id: created.id,
type: card.type,
fields: card.fields,
});
}
return { deck: created, cards_created: res.suggestion.cards.length };
}
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);
}