Some checks are pending
CI / validate (push) Waiting to run
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>
191 lines
6.2 KiB
TypeScript
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);
|
|
}
|