syncova-backup/apps/web/src/api/client.ts
Jerrit Fritzsche b3f0a99243
Some checks failed
CI / Backend (Go) (push) Failing after 30s
CI / Frontend (React/TypeScript) (push) Successful in 44s
CI / Sicherheitsprüfungen (push) Successful in 27s
Weboberflaeche: Design-Fundament und bedienbare Auftraege
Ausgangslage, gemessen statt geschaetzt: Von 99 fachlichen Endpunkten rief die
Oberflaeche 23 auf. Schreibend waren es neun, vier davon An- und Abmeldung.
Real verwaltbar war: einen Auftrag anlegen, einen Bericht erzeugen, eine
Meldung bestaetigen. Das ist ein Leseinstrument, keine Verwaltungskonsole.

Dieser Schritt legt das Fundament und macht den ersten Bereich vollstaendig
bedienbar.

Fundament:

- Tailwind v4 und Radix-Primitive (shadcn-Muster). Alles gebuendelt, keine
  externen Ressourcen — die CSP der Auslieferung laesst sie ohnehin nicht zu.
- Farbsystem nach PROMPT.md §106: Semantische Farben ausschliesslich fuer
  Status, sonst neutral. Die Zuordnung der Fachbegriffe auf die fuenf
  Bedeutungen steht an genau **einer** Stelle (StatusBadge). Verteilt ueber die
  Seiten erschiene frueher oder spaeter irgendwo "partial_failure" gruen, und
  ein Betreiber haelt einen Teilfehler dann fuer einen Erfolg. Ein unbekannter
  Zustand wird neutral dargestellt, nie gruen.
- Neue Seitenhuelle mit fuenf Bereichen, einklappbarer Seitenleiste, Schublade
  auf schmalen Geraeten und Dark Mode ueber ein Attribut am Wurzelelement (nicht
  allein ueber die Medienabfrage — eine Konsole, die nachts waehrend einer
  Stoerung von selbst umschaltet, ist laestig).
- `useMutation` fuer schreibende Aufrufe: Doppelklickschutz, Vorgangsnummer bis
  in die Meldung, kein setState nach dem Aushaengen. `describeApiError`
  uebersetzt die bekannten Fehlercodes in Saetze **mit Abhilfe**.
- Der API-Client sendet jetzt `Idempotency-Key`. Ohne ihn erzeugt ein
  Doppelklick zwei Auftraege — und bei einer Wiederherstellung zwei
  gleichzeitige Laeufe in dasselbe Ziel.
- Fehlermeldungen nennen immer die `request_id`, kopierbar.

Auftraege (Endpunkte, die vorher keine Oberflaeche hatten):

- Lauf anstossen, anhalten, fortsetzen, loeschen, laufenden Lauf abbrechen.
- Detailseite mit Laufhistorie: Fehlercode, Fehlerklasse und die Auskunft, ob
  eine Wiederholung ueberhaupt etwas bringt — ein Anmeldefehler behebt sich
  nicht durch Warten.
- **Ein zweiter Anstoss ist kein Fehler, sondern eine Auskunft.** Der 409 wird
  als Hinweis gezeigt, nicht als Fehlschlag: Der Auftrag laeuft ja, und genau
  das wollte der Betreiber.
- **Loeschen nennt die Folgen.** Die Wiederherstellungspunkte bleiben bestehen;
  sie gehoeren zum Repository, nicht zum Auftrag. Ohne diesen Hinweis loescht
  jemand einen Auftrag in der Annahme, Platz zu schaffen.

Der Wiederherstellungs-Assistent ist gebaut (vier Schritte, Vorabpruefung als
eigener Schritt, die drei Huerden vor dem Ueberschreiben sichtbar umgesetzt),
aber noch nicht in eine Seite eingebunden.

Drei Lint-Befunde behoben, alle dieselbe Sorte wie in Phase 8 und 12:
setState im Effektkoerper und ein Schreibzugriff auf eine Referenz waehrend des
Renderns. Der Bestaetigungsdialog haelt seinen Zustand jetzt im Portalinhalt —
beim Schliessen verschwindet er von selbst, ein Zuruecksetzen im Effekt
entfaellt, und die Huerde steht beim naechsten Oeffnen wieder.

Die noch nicht umgebauten Seiten behalten vorerst das alte Stylesheet. Es faellt
weg, sobald die letzte umgebaut ist.

69 Tests gruen, tsc sauber, eslint ohne Warnung, Bau 373 KB (115 KB gzip).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 09:25:42 +02:00

325 lines
11 KiB
TypeScript

/**
* HTTP-Client fuer die Syncova-API.
*
* Der Client kapselt die Antworthuelle des Backends und liefert Fehler stets als
* ApiError. Aufrufer muessen sich damit nicht mit HTTP-Details befassen und es
* kann keine Fehlerantwort versehentlich als Nutzlast interpretiert werden
* (PROMPT.md §140: keine stillen Fehler).
*/
import type { ErrorResponse, SuccessResponse } from '../types/api';
/** Basis-Pfad aller fachlichen Endpunkte (SYNCOVA_API.md). */
const API_BASE_PATH = '/api/v1';
/** Header, ueber den eine Operation Ende-zu-Ende verfolgt wird (PROMPT.md §50). */
const CORRELATION_ID_HEADER = 'X-Correlation-ID';
/**
* Header, der eine Anfrage genau einmal wirken laesst (SYNCOVA_API.md §5).
*
* Er gehoert an alle anlegenden und zerstoerenden Aufrufe. Ohne ihn erzeugt ein
* Doppelklick oder ein wiederholter Versuch nach einer Zeitueberschreitung zwei
* Auftraege — und bei einer Wiederherstellung zwei gleichzeitige Laeufe in
* dasselbe Ziel.
*/
const IDEMPOTENCY_KEY_HEADER = 'Idempotency-Key';
/**
* Fehler einer API-Anfrage.
*
* Er traegt den maschinenlesbaren Code und die Request-ID, damit ein Anwender
* einen Vorfall gegenueber dem Betreiber eindeutig benennen kann.
*/
export class ApiError extends Error {
/** Stabiler maschinenlesbarer Fehlercode. */
public readonly code: string;
/** HTTP-Statuscode der Antwort. */
public readonly statusCode: number;
/** Kennung des Requests zur Zuordnung im Serverlog. */
public readonly requestId: string;
/** Optionale unbedenkliche Zusatzinformationen. */
public readonly details: Record<string, unknown> | undefined;
constructor(parameters: {
code: string;
message: string;
statusCode: number;
requestId: string;
details?: Record<string, unknown>;
}) {
super(parameters.message);
this.name = 'ApiError';
this.code = parameters.code;
this.statusCode = parameters.statusCode;
this.requestId = parameters.requestId;
this.details = parameters.details;
}
}
/**
* Liefert das aktuelle Zugriffstoken, sofern eine Sitzung besteht.
*
* Der Client kennt die Anmeldelogik bewusst nicht, sondern erhaelt sie ueber
* diese Funktion. Andernfalls entstuende ein Zirkelbezug zwischen dem Client und
* dem Anmeldemodul, das seinerseits den Client verwendet.
*/
let accessTokenProvider: () => string | null = () => null;
/** Hinterlegt die Funktion, die das aktuelle Zugriffstoken liefert. */
export function setAccessTokenProvider(tokenProvider: () => string | null): void {
accessTokenProvider = tokenProvider;
}
/** Fehlercode fuer eine nicht erreichbare API. */
export const NETWORK_ERROR_CODE = 'NETWORK_UNREACHABLE';
/** Fehlercode fuer eine unverstaendliche Antwort. */
export const MALFORMED_RESPONSE_CODE = 'MALFORMED_RESPONSE';
/**
* Erzeugt eine Correlation ID fuer einen Request.
*
* crypto.randomUUID ist in allen unterstuetzten Browsern verfuegbar; der
* Rueckfall deckt aeltere Testumgebungen ab.
*/
function createCorrelationId(): string {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID();
}
// Rueckfall ohne kryptografische Garantie - die Correlation ID dient allein
// der Nachvollziehbarkeit, nicht der Sicherheit.
return `00000000-0000-4000-8000-${Date.now().toString(16).padStart(12, '0').slice(-12)}`;
}
/** Optionen einer API-Anfrage. */
export interface RequestOptions {
/** HTTP-Methode; Standard ist GET. */
method?: 'GET' | 'POST' | 'PATCH' | 'DELETE';
/** Optionaler Anfragekoerper, der als JSON gesendet wird. */
body?: unknown;
/** Signal zum Abbrechen der Anfrage. */
signal?: AbortSignal;
/**
* Schluessel fuer Idempotenz.
*
* `true` erzeugt einen zufaelligen Schluessel; eine Zeichenkette wird
* unveraendert verwendet, damit ein Wiederholungsversuch derselben Handlung
* denselben Schluessel traegt.
*/
idempotencyKey?: string | true;
}
/**
* Fuehrt eine Anfrage gegen die Syncova-API aus.
*
* @param endpointPath Pfad unterhalb von /api/v1, z. B. "/health".
* @returns Die Nutzlast der Antwort.
* @throws ApiError bei jedem Fehlerfall.
*/
export async function requestApi<TPayload>(
endpointPath: string,
requestOptions: RequestOptions = {},
): Promise<TPayload> {
const correlationId = createCorrelationId();
const requestMethod = requestOptions.method ?? 'GET';
const requestHeaders: Record<string, string> = {
Accept: 'application/json',
[CORRELATION_ID_HEADER]: correlationId,
};
if (requestOptions.body !== undefined) {
requestHeaders['Content-Type'] = 'application/json';
}
if (requestOptions.idempotencyKey !== undefined) {
requestHeaders[IDEMPOTENCY_KEY_HEADER] =
requestOptions.idempotencyKey === true
? createCorrelationId()
: requestOptions.idempotencyKey;
}
// Besteht eine Sitzung, wird sie mitgesendet. Ohne Token laufen die Anfragen
// unauthentifiziert - der Server entscheidet dann ueber den Zugriff.
const accessToken = accessTokenProvider();
if (accessToken !== null) {
requestHeaders['Authorization'] = `Bearer ${accessToken}`;
}
let httpResponse: Response;
try {
httpResponse = await fetch(`${API_BASE_PATH}${endpointPath}`, {
method: requestMethod,
headers: requestHeaders,
body: requestOptions.body === undefined ? null : JSON.stringify(requestOptions.body),
// Die Sitzung laeuft ueber ein Cookie bzw. einen Token desselben Ursprungs.
credentials: 'same-origin',
...(requestOptions.signal ? { signal: requestOptions.signal } : {}),
});
} catch (networkError) {
// Ein Abbruch durch den Aufrufer ist kein Fehlerfall und wird durchgereicht.
if (networkError instanceof DOMException && networkError.name === 'AbortError') {
throw networkError;
}
throw new ApiError({
code: NETWORK_ERROR_CODE,
message: 'Syncova ist derzeit nicht erreichbar. Bitte Netzwerkverbindung und Dienststatus pruefen.',
statusCode: 0,
requestId: correlationId,
});
}
// 204 traegt per Definition keinen Koerper.
if (httpResponse.status === 204) {
return undefined as TPayload;
}
let parsedBody: unknown;
try {
parsedBody = await httpResponse.json();
} catch {
throw new ApiError({
code: MALFORMED_RESPONSE_CODE,
message: 'Die Antwort des Servers war unverstaendlich.',
statusCode: httpResponse.status,
requestId: httpResponse.headers.get('X-Request-ID') ?? correlationId,
});
}
// Massgeblich ist die Antworthuelle, nicht allein der HTTP-Status.
//
// Beide Angaben tragen unterschiedliche Aussagen: der Status beschreibt den
// Betriebszustand, die Huelle den Inhalt. GET /api/v1/health nutzt genau diese
// Trennung und meldet einen kritischen Systemzustand mit 503, liefert dabei
// aber einen vollstaendigen Bericht als Nutzlast. Wuerde der Client jeden
// Status ausserhalb von 2xx als inhaltsleeren Fehler behandeln, ginge
// ausgerechnet die Diagnose verloren, die der Anwender jetzt braucht.
const errorResponse = parsedBody as Partial<ErrorResponse>;
if (errorResponse.error) {
throw new ApiError({
code: errorResponse.error.code,
message: errorResponse.error.message,
statusCode: httpResponse.status,
requestId: errorResponse.error.request_id,
...(errorResponse.error.details ? { details: errorResponse.error.details } : {}),
});
}
const successResponse = parsedBody as Partial<SuccessResponse<TPayload>>;
if (successResponse.data === undefined) {
throw new ApiError({
code: MALFORMED_RESPONSE_CODE,
message: httpResponse.ok
? 'Die Antwort des Servers enthielt keine Daten.'
: 'Der Server meldete einen Fehler ohne verwertbare Beschreibung.',
statusCode: httpResponse.status,
requestId: httpResponse.headers.get('X-Request-ID') ?? correlationId,
});
}
return successResponse.data;
}
/**
* Laedt eine Datei von der API herunter.
*
* Sie steht neben requestApi und nicht darin: Eine Datei traegt **keine**
* Antworthuelle, sondern ist der Inhalt selbst. Wuerde man sie durch requestApi
* schicken, versuchte dieser, ein PDF als JSON zu lesen, und meldete eine
* unverstaendliche Antwort — obwohl alles in Ordnung ist.
*
* Der Fehlerfall geht dagegen sehr wohl durch die Huelle: Scheitert die Anfrage,
* antwortet der Server mit JSON. Deshalb wird der Inhaltstyp geprueft, bevor die
* Antwort als Datei behandelt wird — sonst landete eine Fehlermeldung als
* „bericht.pdf" im Download-Ordner, und der Anwender saehe statt einer Meldung
* eine kaputte Datei.
*/
export async function downloadApiFile(
endpointPath: string,
requestOptions: RequestOptions = {},
): Promise<{ blob: Blob; fileName: string }> {
const correlationId = createCorrelationId();
const requestHeaders: Record<string, string> = {
[CORRELATION_ID_HEADER]: correlationId,
};
if (requestOptions.body !== undefined) {
requestHeaders['Content-Type'] = 'application/json';
}
const accessToken = accessTokenProvider();
if (accessToken !== null) {
requestHeaders['Authorization'] = `Bearer ${accessToken}`;
}
let httpResponse: Response;
try {
httpResponse = await fetch(`${API_BASE_PATH}${endpointPath}`, {
method: requestOptions.method ?? 'POST',
headers: requestHeaders,
body: requestOptions.body === undefined ? null : JSON.stringify(requestOptions.body),
credentials: 'same-origin',
...(requestOptions.signal ? { signal: requestOptions.signal } : {}),
});
} catch (networkError) {
if (networkError instanceof DOMException && networkError.name === 'AbortError') {
throw networkError;
}
throw new ApiError({
code: NETWORK_ERROR_CODE,
message: 'Syncova ist derzeit nicht erreichbar. Bitte Netzwerkverbindung und Dienststatus pruefen.',
statusCode: 0,
requestId: correlationId,
});
}
const responseContentType = httpResponse.headers.get('Content-Type') ?? '';
// Eine JSON-Antwort auf eine Dateianfrage ist immer ein Fehler.
if (responseContentType.includes('application/json')) {
const parsedBody = (await httpResponse.json()) as Partial<ErrorResponse>;
throw new ApiError({
code: parsedBody.error?.code ?? MALFORMED_RESPONSE_CODE,
message: parsedBody.error?.message ?? 'Die Datei konnte nicht erzeugt werden.',
statusCode: httpResponse.status,
requestId: parsedBody.error?.request_id ?? correlationId,
});
}
if (!httpResponse.ok) {
throw new ApiError({
code: MALFORMED_RESPONSE_CODE,
message: 'Die Datei konnte nicht erzeugt werden.',
statusCode: httpResponse.status,
requestId: httpResponse.headers.get('X-Request-ID') ?? correlationId,
});
}
return {
blob: await httpResponse.blob(),
fileName: parseFileNameFromDisposition(httpResponse.headers.get('Content-Disposition')),
};
}
/**
* Liest den Dateinamen aus dem Content-Disposition-Kopf.
*
* Ohne verwertbaren Kopf bleibt der Name leer und der Aufrufer waehlt einen —
* ein erfundener Name aus dem Kopf zu lesen waere schlimmer als keiner.
*/
function parseFileNameFromDisposition(dispositionHeader: string | null): string {
if (dispositionHeader === null) {
return '';
}
const fileNameMatch = /filename="([^"]+)"/.exec(dispositionHeader);
return fileNameMatch === null ? '' : (fileNameMatch[1] ?? '');
}