This commit is contained in:
seb
2026-08-01 01:36:44 +02:00
parent 9582370b0b
commit 6d720d34ed
6 changed files with 79 additions and 5 deletions

View File

@@ -0,0 +1,53 @@
---
description: Nie API-Verträge raten — immer Backend-Doku lesen
alwaysApply: true
---
# Keine angenommenen APIs
API-Shapes, Endpoints und Response-Felder **niemals erraten**, **niemals** aus ähnlichen Endpoints ableiten, **niemals** im Chat „vorschlagen“ und dann direkt implementieren.
## Quelle der Wahrheit (in dieser Reihenfolge)
1. **Laufendes Backend** — `dashback/docs/api/` (Markdown pro Route) und `dashback/docs/api/openapi.json`
2. **`src/api/index.js`** — nur Spiegel des Backend-Vertrags, keine Erfindung
3. **Explizite User-Vorgabe** — nur wenn der User den Vertrag wörtlich nennt
Fehlt (1): **nicht implementieren**. Stattdessen Doku lesen oder User fragen.
## Verboten
```js
// ❌ „PATCH liefert { article }, also sicher auch POST …/image“
requireObject(res.article, 'article');
// ❌ Endpoint oder Feldname erfinden
return httpMultipart(`/api/article/${sku}/photos`, …);
// ❌ Response-Shape aus Konvention ableiten
onArticleUpdated?.(res.article ?? res.data);
```
```js
// ✅ Vertrag aus dashback übernommen, in index.js dokumentiert
// POST /api/article/:sku/image → 201 { image: { picId, sort, full, thumb, hash, thumbHash } }
requireObject(res.image, 'image');
```
## Pflicht-Workflow bei neuem oder geändertem Endpoint
1. Route in `dashback/docs/api` oder `openapi.json` finden und lesen
2. Method, Path, Request-Body, Status-Codes, Response-JSON exakt notieren
3. `src/api/index.js` kommentieren und Client-Methode schreiben
4. Response mit `requireObject` / `requireArray` / `requireDefined` aus `apiShape.js` validieren
5. Kein Frontend-Code, der Felder nutzt, die nicht im Vertrag stehen (z. B. `photo.url` wenn nur `thumbHash` dokumentiert ist)
## Erlaubt
- **Backend-Doku aktualisieren**, wenn der User den Vertrag bewusst ändert — danach Frontend anpassen
- **Blockieren mit Frage**, wenn Vertrag unklar oder widersprüchlich ist
## Zusammenspiel mit `no-silent-fallbacks`
- Keine Fallbacks auf alternative Feldnamen oder Endpoints
- Kein „vielleicht heißt es `article` oder `image`“ — der Vertrag legt es fest

View File

@@ -180,7 +180,7 @@ export const article = {
/**
* POST /api/article/:sku/image
* Body: raw AVIF bytes (Content-Type: image/avif, max 1000×1000, prepared client-side via WASM)
* → 201 { article }
* → 201 { image: { picId, sort, full, thumb, hash, thumbHash } }
*/
uploadImage(sku, file) {
return httpBinary(`/api/article/${encodeURIComponent(sku)}/image`, {

View File

@@ -2,6 +2,7 @@ import { Component } from 'react';
import { connect } from 'react-redux';
import { enqueueSnackbar } from 'notistack';
import { notifyError } from '../utils/notify';
import { bootstrapSession as bootstrapSessionThunk } from '../store/authSlice';
let sessionBootstrapped = false;

View File

@@ -3,7 +3,9 @@ import { connect } from 'react-redux';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import GoogleIcon from '@mui/icons-material/Google';
import { enqueueSnackbar } from 'notistack';
import { notifyErrorMessage } from '../utils/notify';
import { loginWithGoogle, selectAuthStatus, selectGoogleClientId } from '../store/authSlice';
import {
abortGoogleSignIn,
FEDCM_BLOCKED_MESSAGE,
@@ -61,7 +63,7 @@ class LoginPage extends Component {
const detail = [err?.message || String(err), err?.code, err?.status]
.filter(Boolean)
.join(' · ');
enqueueSnackbar(`Login failed: ${detail}`, { variant: 'error' });
notifyErrorMessage(`Login failed: ${detail}`);
} finally {
this.setState({ busy: false });
}

View File

@@ -6,7 +6,7 @@ import { requireObject } from '../../utils/apiShape';
import { notifyError } from '../../utils/notify';
import { prepareArticlePhoto } from '../../utils/prepareArticlePhoto';
import { pasteImageOrFallback } from '../../utils/pasteImage';
import { primaryPhoto } from '../../utils/articleDetail';
import { articleWithUploadedPhoto, primaryPhoto } from '../../utils/articleDetail';
import ArticlePhoto from './ArticlePhoto';
export default function ArticlePhotoTile({
@@ -28,8 +28,8 @@ export default function ArticlePhotoTile({
const file = await prepareArticlePhoto(rawBlob);
const res = await article.uploadImage(articleData.sku, file);
requireObject(res, 'POST /api/article/:sku/image');
requireObject(res.article, 'article');
onArticleUpdated?.(res.article);
requireObject(res.image, 'image');
onArticleUpdated?.(articleWithUploadedPhoto(articleData, res.image));
} catch (err) {
notifyError(err);
} finally {

View File

@@ -10,6 +10,24 @@ export function primaryPhoto(article) {
return photos[0] ?? null;
}
/** Map POST /api/article/:sku/image → photos[] entry shape. */
export function photoFromUploadedImage(image) {
return {
sort: image.sort,
thumbW: image.thumb.w,
thumbH: image.thumb.h,
thumbHash: image.thumbHash,
};
}
export function articleWithUploadedPhoto(article, image) {
const photo = photoFromUploadedImage(image);
const photos = sortedPhotos(article).filter((row) => row.sort !== photo.sort);
photos.push(photo);
photos.sort((a, b) => a.sort - b.sort);
return { ...article, photos };
}
export function primaryPrice(article) {
if (!Array.isArray(article?.prices) || article.prices.length === 0) return null;
return article.prices.find((row) => row.minmultiplier === 1) ?? article.prices[0];