morz-infoboard/docs/superpowers/plans/2026-03-28-user-spezifische-medien.md
2026-03-28 08:55:36 +01:00

24 KiB
Raw Permalink Blame History

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

-- 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)
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
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 5263) ersetzen durch:

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)
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)
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
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.CreatecreatedByUserID Parameter hinzufügen
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.Getcreated_by_user_id zur Query hinzufügen
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
cd server/backend && go build ./...

Erwartet: keine Fehler. Compilerfehler zeigen alle Call-Sites die noch auf alte Signaturen zeigen.

  • Step 8: Commit
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

// 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:

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
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
cd server/backend && go test ./internal/httpapi/manage/... -run TestCanDeleteMedia -v

Erwartet: 6/6 PASS.

  • Step 5: HandleListMedia aktualisieren — ownerUserID nach Rolle setzen
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:

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:

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
cd server/backend && go build ./... && go test ./internal/httpapi/manage/... -v

Erwartet: kein Compilerfehler, alle Tests PASS.

  • Step 9: Commit
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: HandleManageUImedia.List Call anpassen (ca. Zeile 397)

Den Block:

assets, err := media.List(r.Context(), screen.TenantID)

ersetzen durch:

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: HandleUploadMediaUIcreatedByUserID an Create-Aufrufe übergeben

Am Anfang des Handlers (nach dem Tenant-Slug-Block, ca. Zeile 622) existiert bereits:

if u := reqcontext.UserFromContext(r.Context()); u != nil && u.TenantSlug != "" {
    tenantSlug = u.TenantSlug
}

Direkt danach createdByUserID extrahieren:

createdByUserID := ""
if u := reqcontext.UserFromContext(r.Context()); u != nil {
    createdByUserID = u.ID
}

Dann beide media.Create-Aufrufe (Zeilen 640 und 658) anpassen:

// 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:

// 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
cd server/backend && go build ./...

Erwartet: kein Fehler.

  • Step 5: Commit
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)

// 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: HandleTenantUploadcreatedByUserID an Create-Aufrufe übergeben

Am Anfang des Handlers (nach tenantSlug Extraktion) einfügen:

createdByUserID := ""
if u := reqcontext.UserFromContext(r.Context()); u != nil {
    createdByUserID = u.ID
}

Beide mediaStore.Create-Aufrufe (Zeilen ~161 und ~185) anpassen:

// 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
cd server/backend && go build ./...

Erwartet: kein Fehler.

  • Step 4: Commit
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 <!-- Library --> 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:

/* 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 <!-- Library --> (enthält <h2 class="title is-6 mb-3">Medienbibliothek</h2>).

Den <div class="box"> Block ersetzen durch:

<div class="box">
  <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:.75rem">
    <h2 class="title is-6 mb-0">Medienbibliothek</h2>
    {{if ne .UserRole "restricted"}}
    <button id="toggle-restricted-btn" class="button is-small is-light"
      onclick="toggleRestrictedMedia(this)"
      style="font-size:.75rem">
      Restricted-Medien anzeigen
    </button>
    {{end}}
  </div>
  • Step 3: lib-card mit data-owner-restricted und Besitzer-Badge versehen

Den <div class="lib-card"> innerhalb von {{range .Assets}} ersetzen durch:

<div class="lib-card" data-owner-restricted="{{.OwnerIsRestricted}}">

In <div class="lib-info"> nach dem <div class="lib-title"...> folgendes einfügen:

<div class="lib-badge">{{typeIcon .Type}} {{.Type}}
  {{if .OwnerIsRestricted}}<span class="tag is-info is-light ml-1" style="font-size:.65rem">{{.OwnerUsername}}</span>{{end}}
  {{if and (eq $.UserRole "admin") (not .CreatedByUserID)}}<span class="tag is-warning is-light ml-1" style="font-size:.65rem">Kein Besitzer</span>{{end}}
</div>

Hinweis: not ist eine eingebaute Go-Template-Funktion. Alternativ {{if eq .CreatedByUserID ""}}.

  • Step 4: not Template-Funktion prüfen — Go-Templates haben kein not

Go-Templates kennen kein not. Den Ausdruck von Step 3 für "Kein Besitzer" ersetzen durch:

{{if and (eq $.UserRole "admin") (eq .CreatedByUserID "")}}<span class="tag is-warning is-light ml-1" style="font-size:.65rem">Kein Besitzer</span>{{end}}
  • Step 5: JS-Toggle-Funktion hinzufügen

Suche die vorhandene JS-Sektion (ca. Zeile 652, enthält var texts = {...}). Direkt am Ende des <script>-Blocks (vor </script>) einfügen:

function toggleRestrictedMedia(btn) {
  var lib = document.querySelector('.lib-grid');
  if (!lib) return;
  var showing = lib.classList.toggle('show-restricted');
  btn.textContent = showing ? 'Restricted-Medien ausblenden' : 'Restricted-Medien anzeigen';
  btn.classList.toggle('is-info', showing);
  btn.classList.toggle('is-light', !showing);
}
  • Step 6: Build + Template-Syntax prüfen
cd server/backend && go build ./...

Erwartet: kein Fehler. Go-Templates werden erst zur Laufzeit geparst — Syntax-Fehler erscheinen erst beim ersten Request.

  • Step 7: Manuell testen
docker compose -f compose/server-stack.yml up --build -d backend
  1. Als Restricted User einloggen → Upload ein Medium → nur eigenes Medium sichtbar, kein Toggle-Button
  2. Als Admin einloggen → alle Medien sehen → Toggle-Button vorhanden → anklicken → Restricted-Medien erscheinen mit Besitzer-Badge → Legacy-Medien haben "Kein Besitzer" Badge
  3. Als screen_user einloggen → Restricted-Medien ausgeblendet → Toggle zeigt sie an mit Besitzer-Namen
  • Step 8: Commit
git add server/backend/internal/httpapi/manage/templates.go
git commit -m "feat(ui): Restricted-Medien Toggle + Besitzer-Badge + Kein-Besitzer-Badge"

Task 7: Dokumentation

Files:

  • Modify: docs/SCHEMA.md

  • Modify: docs/API-ENDPOINTS.md

  • Step 1: docs/SCHEMA.mdcreated_by_user_id bei media_assets ergänzen

Die media_assets-Spalten-Liste (ca. Zeile 258280 in SCHEMA.md) um folgende Zeile ergänzen:

created_by_user_id text null references users(id) on delete set null

Direkt nach tenant_id. Außerdem unter "Wichtige Indizes" ergänzen:

create index idx_media_assets_created_by_user_id on media_assets(created_by_user_id);
  • Step 2: docs/API-ENDPOINTS.md — Media-Endpoints dokumentieren

Im Abschnitt "Media" (falls nicht vorhanden: anlegen nach dem bestehenden Abschnitt) hinzufügen:

## Media

### GET /api/v1/tenants/{tenantSlug}/media

Listet alle Medien eines Tenants.

**Rolle `restricted`:** Gibt nur Medien zurück, die der eingeloggte User selbst hochgeladen hat (`created_by_user_id = user.id`).

**Alle anderen Rollen:** Alle Medien des Tenants. Response-Felder `owner_is_restricted` und `owner_username` sind befüllt wenn das Medium von einem Restricted-User hochgeladen wurde.

### POST /api/v1/tenants/{tenantSlug}/media

Lädt ein Medium hoch oder registriert eine URL. Setzt `created_by_user_id` auf die ID des eingeloggten Users.

### DELETE /api/v1/media/{id}

Löscht ein Medium.

- **Admin:** immer erlaubt
- **screen_user:** erlaubt wenn `asset.tenant_id == user.tenant_id`
- **restricted:** erlaubt nur wenn `asset.created_by_user_id == user.id` (eigenes Medium)
  • Step 3: Commit
git add docs/SCHEMA.md docs/API-ENDPOINTS.md
git commit -m "docs: media_assets.created_by_user_id + Berechtigungslogik dokumentiert"