Przejdź do treści

Etap 28 — Media offline-first (zdjęcia produktów + PDF hostowane u nas)

Zaimplementowane + WDROŻONE 2026-06-17 (backend migracja 20260618_etap28_media, kiosk 1.5.70)

Zdjęcia produktów/kategorii i PDF-y (regulaminy/info) są hostowane u nas (S3/MinIO, z fallbackiem na dysk w dev/VM), a kiosk pobiera je raz i trzyma lokalnie (offline-first). Wcześniej były to gołe zewnętrzne URL-e wklejane ręcznie, pobierane per-render bez cache.

Cel

  • Admin wgrywa plik w kartotece zamiast wklejać URL (zewnętrzny link dalej działa jako opcja).
  • Kiosk renderuje z dysku — zdjęcia i PDF działają offline (komplementarne do Offline hardening).
  • Storage zgodny z kontraktem przyszłej platformy deploy (BILLvis): MinIO przez S3 API, path-style, bucket=namespace, stream przez backend (MinIO in-cluster only → presigned nie dociera do przeglądarki/kiosku).

Architektura

Backend — port MediaStorage (28.1)

  • Interfejs MediaStorage (put/getStream/exists/delete) z dwoma adapterami, wybór z ENV przy boot:
    • S3Storage — gdy ustawione S3_ENDPOINT + S3_BUCKET. Czyta kontrakt S3_ENDPOINT/S3_BUCKET/S3_ACCESS_KEY/S3_SECRET_KEY/S3_REGION/S3_FORCE_PATH_STYLE (@aws-sdk/client-s3).
    • DiskStorage — fallback (MEDIA_DIR, domyślnie /data/media) dla dev + obecnego single-VM, dopóki nie ma MinIO. Nie dla docelowego k8s (brak PVC → tam S3 obowiązkowe).
  • Model Mediacontent-addressed: sha256 (unique) = klucz obiektu (media/<sha256>), dedup za darmo; contentType/size/kind(IMAGE|PDF|OTHER)/originalName/uploadedById.
  • POST /media (ADMIN, multer, walidacja MIME + rozmiar 25 MB) → hash + dedup + put → { id, url:"/media/<id>" }.
  • GET /media/:id (PUBLIC — id niezgadywalny uuid, treść jawna; Cache-Control: immutable + ETag=sha) → backend streamuje bajty z magazynu (nie presigned URL).
  • Nullable FK Item.photoMediaId / ItemGroup.imageMediaId / ItemDocument.mediaIdzarezerwowane na przyszły manifest/integrity. Legacy *Url zostają.

URL = relatywne /media/<id> (28.2)

Po wgraniu web zapisuje relatywne /media/<id> wprost w istniejących polach photoUrl/imageUrl/url. Dzięki temu wszystkie serializery (catalog, /machines/me/sync, kiosk) działają bez zmian — przepuszczają string dalej. Klienci resolvują /media/<id> → swój API base (kontrakt „nie zaszywaj hosta backendu"). Brak osobnego manifest-endpointu — kiosk czerpie referencje z już synchronizowanego photoUrl.

Web — upload w kartotece (28.3)

MediaUploadButton (file picker → server action uploadMediaPOST /media) przy polu zdjęcia produktu i URL dokumentu. Podgląd miniatury. Helper mediaSrc(url): relatywne /media/<id> → API base, zewnętrzne URL bez zmian. Lista + link PDF renderują przez mediaSrc.

Kiosk — cache offline-first (28.4, 1.5.70)

  • MediaCache (singleton, attach(ApiClient) w app.dart): /media/<id> jest immutable, więc „plik na dysku?" wystarcza za inwalidację. Cache w C:\smartbox-kiosk\media\<id>. resolveUrl relatywne → apiBase.
  • Lazy cache przy renderze + precache na każdym syncu (cached_items.photoUrl LIKE '/media/%').
  • KioskMediaImage zastępuje Image.network (wydawanie: kafel/karta/hero; inwentaryzacja: kolumna) — cache→Image.file (offline), inaczej Image.network + dociąg w tle.
  • PDF viewer: hosted /media/<id> → bajty z cache (offline-first); zewn. URL → http.get jak dotąd.
  • ApiClient: getBytes(path) + apiBase getter.

Deploy (28.5)

  • Compose: wolumen media_files:/data/media + ENV MEDIA_DIR i S3_* (puste → dysk; ustawione → S3 auto-switch) na backendzie.
  • Wdrożone 2026-06-17: backend (migracja) + web na VM, kiosk 1.5.70 STABLE → ota.auto-rollout na 5 maszyn. Backend wystartował na DiskStorage (/data/media) — brak MinIO na tej VM.

Gdy pojawi się MinIO

Wystarczy podać backendowi ENV S3_ENDPOINT + S3_BUCKET + S3_ACCESS_KEY + S3_SECRET_KEY (region/path-style mają sensowne domyślne) i zrestartować — przełącza się na S3 automatycznie, zero zmian w kodzie. Nowe pliki lecą do MinIO; stare z dysku można domigrować osobno (opcjonalnie). Pełny kontrakt platformy: C:\Projekty\BILLvis\platform\CONTRACT.md (S3 ENV + reszta wymagań deploy).

Otwarte / opcjonalne

  • Backfill istniejących zewnętrznych URL-i do Media (gdyby chcieć wszystko hostować).
  • Cap/LRU na katalog media kiosku (na razie bez limitu).
  • Wykorzystanie FK *MediaId na manifest „które media na tej maszynie" + integrity (onDelete: SetNull).
  • Backup wolumenów (media_files, release_files, postgres_data) — wspólny follow-up.

Powiązane