wordeck/apps/web/src/lib/api/decks.ts
Till JS 823560900c
Some checks are pending
CI / validate (push) Waiting to run
feat(web): event-sync migration L-1f Phase B — anonymous-mode + lokal-first CRUD
L-1f Phase B von mana/docs/playbooks/LOCAL_FIRST_LOGIN_OPTIONAL.md:

apps/web:
- neue Foundation: lib/sync.svelte.ts mit @mana/event-sync v0.5.0,
  Vault-Crypto via createMasterKeyProviderFromVault, anonymous-Mode
  aktiv (getToken returnt null bis Login)
- lib/api/event-adapters.ts: konvertiert WordeckDeckState/CardState/
  ReviewState (camelCase, event-sourced) → Deck/Card/Review
  (snake_case, @wordeck/domain), stable aggregate-id-Helpers
- lib/api/event-builder.ts: baut EventEnvelope mit aktuellem
  attributedToUserId (real-user-id wenn signed-in, anon:<ulid> sonst)
- lib/api/decks.ts: list/get/create/update/delete/archive auf event-sync.
  Marketplace-Source, duplicate, AI-generate bleiben HTTP (cross-user
  bzw. Tier-gated, Server-AI-Calls)
- lib/api/cards.ts: list/get/create/update/delete auf event-sync,
  Review-Init-Events beim CardCreate (basic-reverse → 2 Reviews),
  content_hash client-side via @wordeck/domain
- lib/api/reviews.ts: listDueReviews via aggregateList, gradeReview
  rechnet FSRS client-side via @wordeck/domain.gradeReview, emittet
  ReviewGraded mit prevSnapshotJson für undo
- routes/+page.svelte: kein redirect-to-/login mehr — direkt /decks
  (anonymous-Mode trägt's)
- routes/auth/callback: ruft onSignedIn(realUserId) für anonyme→real
  Re-Tagging der Outbox + EventLog

shared-schemas/wordeck:
- CardType-Korrektur: 'type-in' → 'typing' (matched @wordeck/domain
  CardType-Schema). Schema-Hash neu: da67d23cf100…, re-published auf
  sync2.mana.how + @mana/shared-schemas@0.1.10

Verbleibend für L-1f Phase C (nächste Session):
- Wordeck-API server-seitige Routes auf Read-Only-Projektion umstellen
  (heute noch Read+Write, wir lassen es da bis wir sicher sind dass
  event-sync stabil läuft)
- /api/v1/public/feed Endpoint (oder Marketplace-Alias)
- DSE-Update (lokale Speicherung, anonyme Nutzung erwähnen)
- Live-Smoke auf wordeck.com

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

191 lines
6.2 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-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);
}