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

784 lines
24 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 5263) 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 `<!-- 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:
```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 `<!-- Library -->` (enthält `<h2 class="title is-6 mb-3">Medienbibliothek</h2>`).
Den `<div class="box">` Block ersetzen durch:
```html
<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:
```html
<div class="lib-card" data-owner-restricted="{{.OwnerIsRestricted}}">
```
In `<div class="lib-info">` nach dem `<div class="lib-title"...>` folgendes einfügen:
```html
<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:
```html
{{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:
```javascript
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**
```bash
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**
```bash
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**
```bash
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.md` — `created_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:
```sql
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:
```markdown
## 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**
```bash
git add docs/SCHEMA.md docs/API-ENDPOINTS.md
git commit -m "docs: media_assets.created_by_user_id + Berechtigungslogik dokumentiert"
```