docs: Implementierungsplan user-spezifische Medien

This commit is contained in:
Jesko Anschütz 2026-03-28 08:55:36 +01:00
parent 922cb905b3
commit e6ad773e74

View file

@ -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 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"
```