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