diff --git a/docs/superpowers/plans/2026-03-28-user-spezifische-medien.md b/docs/superpowers/plans/2026-03-28-user-spezifische-medien.md new file mode 100644 index 0000000..95393ec --- /dev/null +++ b/docs/superpowers/plans/2026-03-28-user-spezifische-medien.md @@ -0,0 +1,784 @@ +# User-spezifische Medien für Restricted Users — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Restricted Users sehen und verwalten nur eigene Medien; Admins/screen_users sehen alle Medien mit Toggle zum Ein-/Ausblenden von Restricted-Medien. + +**Architecture:** Ownership-Feld in DB, Filter im Store-Layer (LEFT JOIN für owner-Info), Rolle-basierte Handler-Logik, clientseitiger JS-Toggle. Neue Hilfsfunktion `canDeleteMedia` für isoliert testbare Berechtigungslogik. + +**Tech Stack:** Go (pgx, net/http), PostgreSQL, Go-Templates, Vanilla JS, Bulma CSS + +--- + +## Betroffene Dateien + +| Datei | Art | +|---|---| +| `server/backend/db/migrations/007_media_owner.sql` | Neu | +| `server/backend/internal/store/store.go` | Modify — MediaAsset, scanMedia, scanMediaFull, List, Create, Get | +| `server/backend/internal/httpapi/manage/media.go` | Modify — canDeleteMedia + Handler | +| `server/backend/internal/httpapi/manage/media_test.go` | Neu — Tests | +| `server/backend/internal/httpapi/manage/ui.go` | Modify — HandleManageUI, HandleUploadMediaUI, HandleDeleteMediaUI | +| `server/backend/internal/httpapi/tenant/tenant.go` | Modify — HandleTenantDashboard, HandleTenantUpload | +| `server/backend/internal/httpapi/manage/templates.go` | Modify — Badge, Toggle, JS | +| `docs/SCHEMA.md` | Modify | +| `docs/API-ENDPOINTS.md` | Modify | + +--- + +### Task 1: DB Migration — `created_by_user_id` + +**Files:** +- Create: `server/backend/db/migrations/007_media_owner.sql` + +- [ ] **Step 1: Migrationsdatei anlegen** + +```sql +-- 007_media_owner.sql +-- Fügt Besitzer-Referenz zu media_assets hinzu. +-- ON DELETE SET NULL: Medium bleibt erhalten wenn User gelöscht wird. + +ALTER TABLE media_assets + ADD COLUMN created_by_user_id text NULL + REFERENCES users(id) ON DELETE SET NULL; + +CREATE INDEX idx_media_assets_created_by_user_id + ON media_assets(created_by_user_id); +``` + +- [ ] **Step 2: Migration ausführen (lokal gegen Dev-DB)** + +```bash +docker compose -f compose/server-stack.yml exec -T db \ + psql -U morz -d morz -f /dev/stdin < server/backend/db/migrations/007_media_owner.sql +``` + +Erwartet: `ALTER TABLE`, `CREATE INDEX`, kein ERROR. + +- [ ] **Step 3: Commit** + +```bash +git add server/backend/db/migrations/007_media_owner.sql +git commit -m "feat(db): created_by_user_id zu media_assets hinzufügen" +``` + +--- + +### Task 2: Store-Layer — Struct, Scan, List, Create, Get + +**Files:** +- Modify: `server/backend/internal/store/store.go` + +Kontext: `MediaAsset` liegt bei ca. Zeile 52. `scanMedia` bei Zeile 416, `List` bei 370, `Create` bei 400, `Get` bei 391. + +- [ ] **Step 1: `MediaAsset`-Struct erweitern** + +Aktuelles Struct (Zeilen 52–63) ersetzen durch: + +```go +type MediaAsset struct { + ID string `json:"id"` + TenantID string `json:"tenant_id"` + Title string `json:"title"` + Type string `json:"type"` // image | video | pdf | web + StoragePath string `json:"storage_path,omitempty"` + OriginalURL string `json:"original_url,omitempty"` + MimeType string `json:"mime_type,omitempty"` + SizeBytes int64 `json:"size_bytes,omitempty"` + Enabled bool `json:"enabled"` + CreatedAt time.Time `json:"created_at"` + CreatedByUserID string `json:"created_by_user_id,omitempty"` + OwnerIsRestricted bool `json:"owner_is_restricted,omitempty"` + OwnerUsername string `json:"owner_username,omitempty"` +} +``` + +- [ ] **Step 2: `scanMedia` aktualisieren (11 Felder, +`created_by_user_id`)** + +```go +func scanMedia(row interface { + Scan(dest ...any) error +}) (*MediaAsset, error) { + var m MediaAsset + err := row.Scan(&m.ID, &m.TenantID, &m.Title, &m.Type, + &m.StoragePath, &m.OriginalURL, &m.MimeType, &m.SizeBytes, + &m.Enabled, &m.CreatedAt, &m.CreatedByUserID) + if err != nil { + return nil, fmt.Errorf("scan media: %w", err) + } + return &m, nil +} +``` + +- [ ] **Step 3: Neue Funktion `scanMediaFull` direkt nach `scanMedia` einfügen (13 Felder)** + +```go +func scanMediaFull(row interface { + Scan(dest ...any) error +}) (*MediaAsset, error) { + var m MediaAsset + var ownerRole string + err := row.Scan(&m.ID, &m.TenantID, &m.Title, &m.Type, + &m.StoragePath, &m.OriginalURL, &m.MimeType, &m.SizeBytes, + &m.Enabled, &m.CreatedAt, &m.CreatedByUserID, + &ownerRole, &m.OwnerUsername) + if err != nil { + return nil, fmt.Errorf("scan media full: %w", err) + } + m.OwnerIsRestricted = ownerRole == "restricted" + return &m, nil +} +``` + +- [ ] **Step 4: `MediaStore.List` ersetzen — neue Signatur + LEFT JOIN** + +```go +func (s *MediaStore) List(ctx context.Context, tenantID, ownerUserID string) ([]*MediaAsset, error) { + const base = ` + SELECT m.id, m.tenant_id, m.title, m.type, + coalesce(m.storage_path,''), coalesce(m.original_url,''), + coalesce(m.mime_type,''), coalesce(m.size_bytes,0), + m.enabled, m.created_at, coalesce(m.created_by_user_id,''), + coalesce(u.role,''), coalesce(u.username,'') + FROM media_assets m + LEFT JOIN users u ON m.created_by_user_id = u.id + WHERE m.tenant_id=$1` + + var rows pgx.Rows + var err error + if ownerUserID != "" { + rows, err = s.pool.Query(ctx, base+` AND m.created_by_user_id=$2 ORDER BY m.created_at DESC`, tenantID, ownerUserID) + } else { + rows, err = s.pool.Query(ctx, base+` ORDER BY m.created_at DESC`, tenantID) + } + if err != nil { + return nil, err + } + defer rows.Close() + var out []*MediaAsset + for rows.Next() { + m, err := scanMediaFull(rows) + if err != nil { + return nil, err + } + out = append(out, m) + } + return out, rows.Err() +} +``` + +- [ ] **Step 5: `MediaStore.Create` — `createdByUserID` Parameter hinzufügen** + +```go +func (s *MediaStore) Create(ctx context.Context, tenantID, title, assetType, storagePath, originalURL, mimeType, createdByUserID string, sizeBytes int64) (*MediaAsset, error) { + row := s.pool.QueryRow(ctx, + `INSERT INTO media_assets(tenant_id, title, type, storage_path, original_url, mime_type, size_bytes, created_by_user_id) + VALUES($1,$2,$3,nullif($4,''),nullif($5,''),nullif($6,''),nullif($7,0),nullif($8,'')) + RETURNING id, tenant_id, title, type, coalesce(storage_path,''), + coalesce(original_url,''), coalesce(mime_type,''), coalesce(size_bytes,0), + enabled, created_at, coalesce(created_by_user_id,'')`, + tenantID, title, assetType, storagePath, originalURL, mimeType, sizeBytes, createdByUserID) + return scanMedia(row) +} +``` + +- [ ] **Step 6: `MediaStore.Get` — `created_by_user_id` zur Query hinzufügen** + +```go +func (s *MediaStore) Get(ctx context.Context, id string) (*MediaAsset, error) { + row := s.pool.QueryRow(ctx, + `SELECT id, tenant_id, title, type, coalesce(storage_path,''), + coalesce(original_url,''), coalesce(mime_type,''), coalesce(size_bytes,0), + enabled, created_at, coalesce(created_by_user_id,'') + FROM media_assets WHERE id=$1`, id) + return scanMedia(row) +} +``` + +- [ ] **Step 7: Build prüfen** + +```bash +cd server/backend && go build ./... +``` + +Erwartet: keine Fehler. Compilerfehler zeigen alle Call-Sites die noch auf alte Signaturen zeigen. + +- [ ] **Step 8: Commit** + +```bash +git add server/backend/internal/store/store.go +git commit -m "feat(store): MediaAsset-Ownership — List/Create/Get mit created_by_user_id" +``` + +--- + +### Task 3: `manage/media.go` — Handler + `canDeleteMedia` + Tests + +**Files:** +- Modify: `server/backend/internal/httpapi/manage/media.go` +- Create: `server/backend/internal/httpapi/manage/media_test.go` + +- [ ] **Step 1: `canDeleteMedia` Hilfsfunktion am Ende von `media.go` hinzufügen** + +```go +// canDeleteMedia prüft ob User u das Medium asset löschen darf. +// admin: immer erlaubt +// screen_user: Tenant-Match reicht +// restricted: Tenant-Match UND Besitzer-Match +func canDeleteMedia(u *store.User, asset *store.MediaAsset) bool { + if u.Role == "admin" { + return true + } + if u.TenantID != asset.TenantID { + return false + } + if u.Role == "restricted" { + return asset.CreatedByUserID == u.ID + } + return true +} +``` + +- [ ] **Step 2: Testdatei anlegen und failing Tests schreiben** + +`server/backend/internal/httpapi/manage/media_test.go`: + +```go +package manage + +import ( + "testing" + + "git.az-it.net/az/morz-infoboard/server/backend/internal/store" +) + +func TestCanDeleteMedia(t *testing.T) { + asset := &store.MediaAsset{TenantID: "t1", CreatedByUserID: "u1"} + + tests := []struct { + name string + user *store.User + allowed bool + }{ + { + name: "admin darf immer", + user: &store.User{Role: "admin", TenantID: "t2", ID: "x"}, + allowed: true, + }, + { + name: "screen_user eigener Tenant", + user: &store.User{Role: "screen_user", TenantID: "t1", ID: "x"}, + allowed: true, + }, + { + name: "screen_user fremder Tenant", + user: &store.User{Role: "screen_user", TenantID: "t2", ID: "x"}, + allowed: false, + }, + { + name: "restricted eigenes Medium", + user: &store.User{Role: "restricted", TenantID: "t1", ID: "u1"}, + allowed: true, + }, + { + name: "restricted fremdes Medium gleicher Tenant", + user: &store.User{Role: "restricted", TenantID: "t1", ID: "u2"}, + allowed: false, + }, + { + name: "restricted fremder Tenant", + user: &store.User{Role: "restricted", TenantID: "t2", ID: "u1"}, + allowed: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := canDeleteMedia(tt.user, asset) + if got != tt.allowed { + t.Errorf("canDeleteMedia() = %v, want %v", got, tt.allowed) + } + }) + } +} +``` + +- [ ] **Step 3: Tests ausführen — müssen FAIL wegen fehlendem `canDeleteMedia`** + +```bash +cd server/backend && go test ./internal/httpapi/manage/... -run TestCanDeleteMedia -v +``` + +Erwartet: FAIL (undefined: canDeleteMedia) oder compile error. + +- [ ] **Step 4: `canDeleteMedia` ist bereits in Step 1 geschrieben — Tests nochmal ausführen** + +```bash +cd server/backend && go test ./internal/httpapi/manage/... -run TestCanDeleteMedia -v +``` + +Erwartet: 6/6 PASS. + +- [ ] **Step 5: `HandleListMedia` aktualisieren — ownerUserID nach Rolle setzen** + +```go +func HandleListMedia(tenants *store.TenantStore, media *store.MediaStore) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + tenant, err := tenants.Get(r.Context(), r.PathValue("tenantSlug")) + if err != nil { + http.Error(w, "tenant not found", http.StatusNotFound) + return + } + u := reqcontext.UserFromContext(r.Context()) + ownerUserID := "" + if u != nil && u.Role == "restricted" { + ownerUserID = u.ID + } + assets, err := media.List(r.Context(), tenant.ID, ownerUserID) + if err != nil { + http.Error(w, "db error", http.StatusInternalServerError) + return + } + if assets == nil { + assets = []*store.MediaAsset{} + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(assets) //nolint:errcheck + } +} +``` + +- [ ] **Step 6: `HandleUploadMedia` aktualisieren — user.ID an Create übergeben** + +Beide `media.Create`-Aufrufe in der Funktion müssen `createdByUserID` erhalten. Den User am Anfang der Funktion extrahieren, dann an beide Create-Calls anhängen: + +```go +func HandleUploadMedia(tenants *store.TenantStore, media *store.MediaStore, uploadDir string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + tenant, err := tenants.Get(r.Context(), r.PathValue("tenantSlug")) + if err != nil { + http.Error(w, "tenant not found", http.StatusNotFound) + return + } + tenantID := tenant.ID + + u := reqcontext.UserFromContext(r.Context()) + createdByUserID := "" + if u != nil { + createdByUserID = u.ID + } + + // W3: MaxBytesReader begrenzt den gesamten Request-Body auf maxUploadSize. + r.Body = http.MaxBytesReader(w, r.Body, maxUploadSize) + if err := r.ParseMultipartForm(maxUploadSize); err != nil { + http.Error(w, "request too large or not multipart", http.StatusBadRequest) + return + } + + assetType := strings.TrimSpace(r.FormValue("type")) + title := strings.TrimSpace(r.FormValue("title")) + + switch assetType { + case "web": + url := strings.TrimSpace(r.FormValue("url")) + if url == "" { + http.Error(w, "url required for type=web", http.StatusBadRequest) + return + } + if title == "" { + title = url + } + asset, err := media.Create(r.Context(), tenantID, title, "web", "", url, "", createdByUserID, 0) + if err != nil { + http.Error(w, "db error", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(asset) //nolint:errcheck + return + + case "image", "video", "pdf": + file, header, err := r.FormFile("file") + if err != nil { + http.Error(w, "file required for type="+assetType, http.StatusBadRequest) + return + } + defer file.Close() + + mimeType := header.Header.Get("Content-Type") + if detectedType := mimeToAssetType(mimeType); detectedType != "" { + assetType = detectedType + } + if title == "" { + title = strings.TrimSuffix(header.Filename, filepath.Ext(header.Filename)) + } + + storagePath, size, err := fileutil.SaveUploadedFile(file, header.Filename, title, uploadDir, r.PathValue("tenantSlug")) + if err != nil { + http.Error(w, "storage error", http.StatusInternalServerError) + return + } + + asset, err := media.Create(r.Context(), tenantID, title, assetType, storagePath, "", mimeType, createdByUserID, size) + if err != nil { + http.Error(w, "db error", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(asset) //nolint:errcheck + + default: + http.Error(w, "type must be one of: image, video, pdf, web", http.StatusBadRequest) + } + } +} +``` + +- [ ] **Step 7: `HandleDeleteMedia` aktualisieren — `canDeleteMedia` verwenden** + +Den aktuellen K3-Block (ab `u := reqcontext...`) ersetzen: + +```go +func HandleDeleteMedia(media *store.MediaStore, uploadDir string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + asset, err := media.Get(r.Context(), id) + if err != nil { + http.Error(w, "not found", http.StatusNotFound) + return + } + + // K3: Rolle-bewusste Berechtigungsprüfung. + u := reqcontext.UserFromContext(r.Context()) + if u == nil || !canDeleteMedia(u, asset) { + http.Error(w, "Forbidden", http.StatusForbidden) + return + } + + if asset.StoragePath != "" { + filename := filepath.Base(asset.StoragePath) + os.Remove(filepath.Join(uploadDir, filename)) //nolint:errcheck + } + + if err := media.Delete(r.Context(), id); err != nil { + http.Error(w, "db error", http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusNoContent) + } +} +``` + +- [ ] **Step 8: Build + Tests** + +```bash +cd server/backend && go build ./... && go test ./internal/httpapi/manage/... -v +``` + +Erwartet: kein Compilerfehler, alle Tests PASS. + +- [ ] **Step 9: Commit** + +```bash +git add server/backend/internal/httpapi/manage/media.go \ + server/backend/internal/httpapi/manage/media_test.go +git commit -m "feat(manage): canDeleteMedia + handler role-awareness für restricted users" +``` + +--- + +### Task 4: `manage/ui.go` — HandleManageUI, HandleUploadMediaUI, HandleDeleteMediaUI + +**Files:** +- Modify: `server/backend/internal/httpapi/manage/ui.go` + +- [ ] **Step 1: `HandleManageUI` — `media.List` Call anpassen (ca. Zeile 397)** + +Den Block: +```go +assets, err := media.List(r.Context(), screen.TenantID) +``` +ersetzen durch: +```go +ownerUserID := "" +if u := reqcontext.UserFromContext(r.Context()); u != nil && u.Role == "restricted" { + ownerUserID = u.ID +} +assets, err := media.List(r.Context(), screen.TenantID, ownerUserID) +``` + +Hinweis: `u` wird weiter unten (Zeile ~437) nochmal aus Context gelesen — das ist kein Problem, `reqcontext.UserFromContext` ist idempotent. + +- [ ] **Step 2: `HandleUploadMediaUI` — `createdByUserID` an Create-Aufrufe übergeben** + +Am Anfang des Handlers (nach dem Tenant-Slug-Block, ca. Zeile 622) existiert bereits: +```go +if u := reqcontext.UserFromContext(r.Context()); u != nil && u.TenantSlug != "" { + tenantSlug = u.TenantSlug +} +``` + +Direkt danach `createdByUserID` extrahieren: +```go +createdByUserID := "" +if u := reqcontext.UserFromContext(r.Context()); u != nil { + createdByUserID = u.ID +} +``` + +Dann beide `media.Create`-Aufrufe (Zeilen 640 und 658) anpassen: + +```go +// web-Zweig (Zeile ~640): +_, err = media.Create(r.Context(), screen.TenantID, title, "web", "", url, "", createdByUserID, 0) + +// file-Zweig (Zeile ~658): +_, err = media.Create(r.Context(), screen.TenantID, title, assetType, storagePath, "", mimeType, createdByUserID, size) +``` + +- [ ] **Step 3: `HandleDeleteMediaUI` — K3-Check für restricted users hinzufügen** + +Nach dem `asset, err := media.Get(...)` Block (ca. Zeile 862) einfügen: + +```go +// K3: Restricted User darf nur eigene Medien löschen. +if u := reqcontext.UserFromContext(r.Context()); u != nil && !canDeleteMedia(u, asset) { + http.Error(w, "Forbidden", http.StatusForbidden) + return +} +``` + +- [ ] **Step 4: Build** + +```bash +cd server/backend && go build ./... +``` + +Erwartet: kein Fehler. + +- [ ] **Step 5: Commit** + +```bash +git add server/backend/internal/httpapi/manage/ui.go +git commit -m "feat(ui): manage-Handler — restricted-aware List/Create/Delete" +``` + +--- + +### Task 5: `tenant/tenant.go` — HandleTenantDashboard + HandleTenantUpload + +**Files:** +- Modify: `server/backend/internal/httpapi/tenant/tenant.go` + +- [ ] **Step 1: `HandleTenantDashboard` — List-Call anpassen (Zeile ~76)** + +```go +// Vorher: +assets, err := mediaStore.List(r.Context(), tenant.ID) + +// Nachher: +assets, err := mediaStore.List(r.Context(), tenant.ID, "") +``` + +Tenant-Dashboard wird nur von Admins und screen_users genutzt (restricted users landen auf `/manage/{screenSlug}`), daher kein ownerUserID-Filter nötig. + +- [ ] **Step 2: `HandleTenantUpload` — `createdByUserID` an Create-Aufrufe übergeben** + +Am Anfang des Handlers (nach `tenantSlug` Extraktion) einfügen: + +```go +createdByUserID := "" +if u := reqcontext.UserFromContext(r.Context()); u != nil { + createdByUserID = u.ID +} +``` + +Beide `mediaStore.Create`-Aufrufe (Zeilen ~161 und ~185) anpassen: + +```go +// web-Zweig (~Zeile 161): +_, err = mediaStore.Create(r.Context(), tenant.ID, title, "web", "", url, "", createdByUserID, 0) + +// file-Zweig (~Zeile 185): +_, err = mediaStore.Create(r.Context(), tenant.ID, title, assetType, storagePath, "", mimeType, createdByUserID, size) +``` + +- [ ] **Step 3: Build** + +```bash +cd server/backend && go build ./... +``` + +Erwartet: kein Fehler. + +- [ ] **Step 4: Commit** + +```bash +git add server/backend/internal/httpapi/tenant/tenant.go +git commit -m "feat(tenant): List/Create mit owner-Feld aktualisiert" +``` + +--- + +### Task 6: `templates.go` — Badge, Toggle, JS + +**Files:** +- Modify: `server/backend/internal/httpapi/manage/templates.go` + +Kontext: Die Medienbibliothek liegt ab ca. Zeile 1083. Zeilen-Nummern können abweichen — suche nach `` als Anker. + +- [ ] **Step 1: CSS für versteckte Restricted-Medien hinzufügen** + +Suche die Upload-Zone CSS-Sektion (ca. Zeile 779, `.upload-zone { ... }`). Direkt **danach** einfügen: + +```css +/* Restricted-Medien: standardmäßig ausgeblendet */ +.lib-card[data-owner-restricted="true"] { display: none; } +.show-restricted .lib-card[data-owner-restricted="true"] { display: flex; } +``` + +- [ ] **Step 2: Toggle-Button und Medienbibliothek-Header anpassen** + +Suche den Block `` (enthält `