- Löschfristen (Migration 0020): pro Mandant einstellbar statt fest im Regelwerk, da die DSGVO selbst keine festen Fristen nennt (Art. 5 Abs. 1 lit. e). Jede Firma wird mit risikogestaffelten Vorschlagswerten vorbelegt, loeschfrist_max_tage wird jetzt tatsächlich hart gegen Werkzeuge gefiltert (schließt rules/OPEN.md Punkt 4). - Neues internal/mail-Paket (SMTP-Versand + Test-Doppel) und darauf aufbauend Passwort-Zurücksetzen (Migration 0019) - bisher nur als Absicht in der Rollentabelle genannt, nie gebaut. - dpf_zertifiziert für alle 20 Katalogeinträge gegen das offizielle DPF-Register recherchiert und in der Produktions-DB aktualisiert (12 zertifiziert, 5 recherchiert-nicht-gefunden, 3 nicht anwendbar). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
487 lines
17 KiB
Go
487 lines
17 KiB
Go
package web
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"github.com/netcell-it/deklarix/internal/rules"
|
|
"github.com/netcell-it/deklarix/internal/store"
|
|
)
|
|
|
|
// antwortenB/C/D-Feldnamen sind bewusst identisch zu den Fakten-
|
|
// Schlüsseln in rules/datenklasse.yaml (b1..b7) und
|
|
// rules/kivo_einstufung.yaml (c1..c5, c2_folge, c3_art) — Schritt 3
|
|
// (Ableitungen) liest dieselben Schlüssel direkt aus antrag.antworten,
|
|
// ohne sie erst umbenennen zu müssen.
|
|
var datenfragen = []struct {
|
|
Key string
|
|
Label string
|
|
}{
|
|
{"b1", "Namen, E-Mail-Adressen oder andere Angaben zu Personen"},
|
|
{"b2", "Angaben zu Gesundheit, Religion, Herkunft, Gewerkschaft, Sexualleben"},
|
|
{"b3", "Daten von Patienten, Mandanten oder Versicherten"},
|
|
{"b4", "Bewerbungs-, Personal- oder Leistungsdaten von Beschäftigten"},
|
|
{"b5", "Daten, die uns ein Kunde anvertraut hat"},
|
|
{"b6", "Konstruktions-, Kalkulations- oder Vertragsunterlagen"},
|
|
{"b7", "Nur allgemein zugängliche oder selbst erfundene Inhalte"},
|
|
}
|
|
|
|
var c2FolgeLabels = map[string]string{
|
|
"beschaeftigung": "Einstellung, Beförderung, Kündigung",
|
|
"kreditwuerdigkeit": "Kreditwürdigkeit",
|
|
"leistungsbewilligung": "Leistungsbewilligung",
|
|
"bildung": "Zugang zu Bildung",
|
|
"gesundheit": "medizinische Beurteilung",
|
|
}
|
|
|
|
var c3ArtLabels = map[string]string{
|
|
"emotionserkennung_arbeitsplatz": "Emotionserkennung am Arbeitsplatz",
|
|
"social_scoring": "Social Scoring",
|
|
"biometrische_kategorisierung": "Biometrische Kategorisierung",
|
|
}
|
|
|
|
// antwortZeile ist eine einzelne Frage-Antwort-Zeile für die Anzeige —
|
|
// siehe antwortenAnzeige.
|
|
type antwortZeile struct {
|
|
Frage string
|
|
Antwort string
|
|
}
|
|
|
|
func antwortLabel(v string) string {
|
|
switch v {
|
|
case "ja":
|
|
return "Ja"
|
|
case "nein":
|
|
return "Nein"
|
|
case "unsicher":
|
|
return "Unsicher"
|
|
default:
|
|
return v
|
|
}
|
|
}
|
|
|
|
func jaNein(v bool) string {
|
|
if v {
|
|
return "Ja"
|
|
}
|
|
return "Nein"
|
|
}
|
|
|
|
// antwortenAnzeige baut aus den rohen Fragebogen-Antworten
|
|
// (antrag.antworten, JSON) eine lesbare Liste — dieselben Fragen-Labels
|
|
// wie im Fragebogen selbst (antrag_neu.html). Ohne das sieht sowohl der
|
|
// antragstellende Mitarbeiter als auch die Fachebene beim Entscheiden
|
|
// nur die daraus abgeleitete Bewertung, nie die tatsächlich gegebenen
|
|
// Antworten, auf denen sie beruht — im Audit ist das nicht
|
|
// nachvollziehbar (siehe CLAUDE.md, Grundregel).
|
|
func antwortenAnzeige(antworten map[string]any) []antwortZeile {
|
|
var out []antwortZeile
|
|
for _, f := range datenfragen {
|
|
v, _ := antworten[f.Key].(string)
|
|
if v == "" {
|
|
v = "nein"
|
|
}
|
|
out = append(out, antwortZeile{Frage: f.Label, Antwort: antwortLabel(v)})
|
|
}
|
|
|
|
c1, _ := antworten["c1"].(bool)
|
|
out = append(out, antwortZeile{Frage: "Geht das Ergebnis unverändert nach außen?", Antwort: jaNein(c1)})
|
|
|
|
c2, _ := antworten["c2"].(bool)
|
|
c2Zeile := antwortZeile{Frage: "Beeinflusst es eine Entscheidung über einen Menschen?", Antwort: jaNein(c2)}
|
|
if c2 {
|
|
if folge, _ := antworten["c2_folge"].(string); folge != "" {
|
|
if label, ok := c2FolgeLabels[folge]; ok {
|
|
c2Zeile.Antwort += " — " + label
|
|
}
|
|
}
|
|
}
|
|
out = append(out, c2Zeile)
|
|
|
|
c3, _ := antworten["c3"].(bool)
|
|
c3Zeile := antwortZeile{Frage: "Erkennt/bewertet es Emotionen, Verhalten oder biometrische Merkmale?", Antwort: jaNein(c3)}
|
|
if c3 {
|
|
if art, _ := antworten["c3_art"].(string); art != "" {
|
|
if label, ok := c3ArtLabels[art]; ok {
|
|
c3Zeile.Antwort += " — " + label
|
|
}
|
|
}
|
|
}
|
|
out = append(out, c3Zeile)
|
|
|
|
c4, _ := antworten["c4"].(bool)
|
|
out = append(out, antwortZeile{Frage: "Läuft es ohne menschliche Prüfung?", Antwort: jaNein(c4)})
|
|
|
|
c5, _ := antworten["c5"].(bool)
|
|
out = append(out, antwortZeile{Frage: "Merkt der Empfänger, dass es von einer KI stammt?", Antwort: jaNein(c5)})
|
|
|
|
if werkzeug, _ := antworten["d_werkzeug_freitext"].(string); werkzeug != "" {
|
|
out = append(out, antwortZeile{Frage: "Gewünschtes Werkzeug", Antwort: werkzeug})
|
|
}
|
|
if zugang, _ := antworten["d_zugang"].(string); zugang != "" {
|
|
label := "Privater Zugang"
|
|
if zugang == "firma" {
|
|
label = "Firmenkonto"
|
|
}
|
|
out = append(out, antwortZeile{Frage: "Zugang", Antwort: label})
|
|
}
|
|
if geraet, _ := antworten["d_geraet"].(string); geraet != "" {
|
|
label := "Privatgerät"
|
|
if geraet == "firma" {
|
|
label = "Firmengerät"
|
|
}
|
|
out = append(out, antwortZeile{Frage: "Gerät", Antwort: label})
|
|
}
|
|
return out
|
|
}
|
|
|
|
type antragFormData struct {
|
|
Title string
|
|
Nav navData
|
|
Abteilungen []store.Abteilung
|
|
Datenfragen []struct {
|
|
Key string
|
|
Label string
|
|
}
|
|
Error string
|
|
}
|
|
|
|
// handleAntragNewForm zeigt den Fragebogen (Ebene 2, "Antrag stellen").
|
|
// Bewusst eine einzelne Seite statt eines mehrseitigen Assistenten —
|
|
// jede zusätzliche Seite kostet Zeit, und "dauert der Antrag länger als
|
|
// fünf Minuten, wird er umgangen" (siehe CLAUDE.md). Adaptive
|
|
// Folgefragen (C2/C3) werden rein clientseitig per CSS ein-/ausgeblendet,
|
|
// ohne zusätzlichen Server-Roundtrip.
|
|
func (s *Server) handleAntragNewForm(w http.ResponseWriter, r *http.Request) {
|
|
if currentUser(r).Role == "betreiber" {
|
|
http.Redirect(w, r, "/betreiber", http.StatusSeeOther)
|
|
return
|
|
}
|
|
abteilungen, err := s.store.ListAbteilungenForAccount(r.Context(), currentUser(r).AccountID)
|
|
if err != nil {
|
|
http.Error(w, "Abteilungen konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
data := antragFormData{Title: "Antrag stellen", Nav: navFor(r), Abteilungen: abteilungen, Datenfragen: datenfragen}
|
|
if err := s.templates.ExecuteTemplate(w, "antrag-neu", data); err != nil {
|
|
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
|
}
|
|
}
|
|
|
|
// antworten ist die JSON-Struktur, die in antrag.antworten gespeichert
|
|
// wird — ein flaches Objekt aus den Fragebogen-Fakten-Schlüsseln.
|
|
// "Unsicher" wird für B unverändert als "unsicher" gespeichert, NICHT
|
|
// zu "ja" normalisiert: diese Normalisierung ("Unsicher zählt wie Ja")
|
|
// ist eine Auswertungsregel (Schritt 3), keine Speicherregel — was der
|
|
// Mitarbeiter tatsächlich geantwortet hat, bleibt im Antrag unverändert
|
|
// erhalten.
|
|
func antwortenFromForm(r *http.Request) []byte {
|
|
m := map[string]any{}
|
|
for _, f := range datenfragen {
|
|
v := r.FormValue(f.Key)
|
|
if v == "" {
|
|
v = "nein"
|
|
}
|
|
m[f.Key] = v
|
|
}
|
|
m["c1"] = r.FormValue("c1") == "ja"
|
|
m["c2"] = r.FormValue("c2") == "ja"
|
|
m["c2_folge"] = r.FormValue("c2_folge")
|
|
m["c3"] = r.FormValue("c3") == "ja"
|
|
m["c3_art"] = r.FormValue("c3_art")
|
|
m["c4"] = r.FormValue("c4") == "ja"
|
|
m["c5"] = r.FormValue("c5") == "ja"
|
|
m["d_werkzeug_freitext"] = r.FormValue("d_werkzeug_freitext")
|
|
m["d_zugang"] = r.FormValue("d_zugang")
|
|
m["d_geraet"] = r.FormValue("d_geraet")
|
|
|
|
data, _ := json.Marshal(m) // map[string]any mit nur primitiven Werten kann nicht scheitern
|
|
return data
|
|
}
|
|
|
|
// handleAntragCreate legt einen Antrag an und reicht ihn direkt ein
|
|
// (entwurf -> eingereicht in einem Schritt) — der Fragebogen ist eine
|
|
// einzelne Seite, ein Zwischenspeichern als Entwurf ist für v1 nicht
|
|
// vorgesehen.
|
|
func (s *Server) handleAntragCreate(w http.ResponseWriter, r *http.Request) {
|
|
if currentUser(r).Role == "betreiber" {
|
|
http.Redirect(w, r, "/betreiber", http.StatusSeeOther)
|
|
return
|
|
}
|
|
if err := r.ParseForm(); err != nil {
|
|
http.Error(w, "ungültiges Formular", http.StatusBadRequest)
|
|
return
|
|
}
|
|
titel := r.FormValue("titel")
|
|
beschreibung := r.FormValue("beschreibung")
|
|
ergebnis := r.FormValue("ergebnis")
|
|
haeufigkeit := r.FormValue("haeufigkeit")
|
|
if titel == "" || beschreibung == "" || ergebnis == "" || haeufigkeit == "" {
|
|
http.Error(w, "Titel, Beschreibung, Ergebnis und Häufigkeit sind Pflichtfelder", http.StatusBadRequest)
|
|
return
|
|
}
|
|
var abteilungID *string
|
|
if v := r.FormValue("abteilung_id"); v != "" {
|
|
abteilungID = &v
|
|
}
|
|
|
|
ctx := r.Context()
|
|
user := currentUser(r)
|
|
|
|
antworten := antwortenFromForm(r)
|
|
|
|
antrag, err := s.store.CreateAntrag(ctx, user.AccountID, user.ID, abteilungID, titel)
|
|
if err != nil {
|
|
http.Error(w, "Antrag konnte nicht angelegt werden: "+err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if _, err := s.store.UpdateAntragFelder(ctx, antrag.ID, titel, beschreibung, ergebnis, haeufigkeit, antworten); err != nil {
|
|
http.Error(w, "Antrag konnte nicht gespeichert werden: "+err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if err := s.store.SetAntragStatus(ctx, antrag.ID, "eingereicht"); err != nil {
|
|
http.Error(w, "Antrag konnte nicht eingereicht werden: "+err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if err := s.bewerteAntrag(ctx, antrag.ID, user.AccountID, antworten); err != nil {
|
|
http.Error(w, "Bewertung konnte nicht berechnet werden: "+err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
http.Redirect(w, r, "/antraege/"+antrag.ID, http.StatusSeeOther)
|
|
}
|
|
|
|
// bewerteAntrag berechnet die Bewertung für einen eingereichten Antrag
|
|
// (Schritt 3: Ableitungen und harte Filter) und speichert sie. Bei
|
|
// "verboten" erfolgt die K.-o.-Prüfung — keine Werkzeugsuche, siehe
|
|
// CLAUDE.md, Bewertungslogik.
|
|
func (s *Server) bewerteAntrag(ctx context.Context, antragID, accountID string, antwortenJSON []byte) error {
|
|
antworten, err := rules.ParseAntworten(antwortenJSON)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
datenklasse := rules.EvaluateDatenklasse(s.regelwerk.Datenklasse, antworten)
|
|
einstufung, err := rules.EvaluateEinstufung(s.regelwerk.Einstufung, antworten)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
verboten := rules.IstVerboten(einstufung.ID)
|
|
|
|
in := store.BewertungInput{
|
|
AntragID: antragID, Datenklasse: datenklasse.ID, DatenklasseHerleitung: datenklasse.Herleitung,
|
|
Einstufung: einstufung.ID, EinstufungHerleitung: einstufungHerleitungText(einstufung), Verboten: verboten,
|
|
KatalogVersion: "n/a (K.-o.-Prüfung, keine Werkzeugsuche)",
|
|
}
|
|
|
|
if !verboten {
|
|
anforderungen := rules.DeriveAnforderungen(s.regelwerk.Anforderungen, datenklasse.ID, einstufung.ID)
|
|
anforderungIDs := make([]string, len(anforderungen))
|
|
for i, a := range anforderungen {
|
|
in.Anforderungen = append(in.Anforderungen, store.BewertungAnforderung{ID: a.ID, Beschreibung: a.Beschreibung, Herleitung: a.Herleitung})
|
|
anforderungIDs[i] = a.ID
|
|
}
|
|
|
|
werkzeuge, err := s.store.ListWerkzeugeForAccount(ctx, accountID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
kandidaten := make([]rules.WerkzeugKandidat, len(werkzeuge))
|
|
for i, w := range werkzeuge {
|
|
kandidaten[i] = rules.WerkzeugKandidat{ID: w.ID, Eigenschaften: rules.WerkzeugEigenschaften{
|
|
AVVVerfuegbar: w.AVVVerfuegbar, Verarbeitungslaender: w.Verarbeitungslaender,
|
|
TrainingOptOut: w.TrainingOptOut, TrainingStandard: w.TrainingStandard, AufbewahrungTage: w.AufbewahrungTage,
|
|
}}
|
|
}
|
|
|
|
var loeschfristMaxTage *int
|
|
loeschfristen, err := s.store.ListLoeschfristEinstellungenForAccount(ctx, accountID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, l := range loeschfristen {
|
|
if l.DatenklasseID == datenklasse.ID {
|
|
maxTage := l.MaxTage
|
|
loeschfristMaxTage = &maxTage
|
|
}
|
|
}
|
|
|
|
zulaessig, ausgeschlossen := rules.FilterWerkzeuge(kandidaten, anforderungIDs, loeschfristMaxTage)
|
|
in.ZulaessigeWerkzeuge = zulaessig
|
|
for _, a := range ausgeschlossen {
|
|
in.AusgeschlosseneWerkzeuge = append(in.AusgeschlosseneWerkzeuge, store.BewertungAusschluss{WerkzeugID: a.WerkzeugID, NichtErfuellt: a.NichtErfuellt})
|
|
}
|
|
|
|
katalogVersion, err := s.store.CurrentKatalogVersion(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
in.KatalogVersion = katalogVersion
|
|
}
|
|
in.RegelwerkVersion = rules.RegelwerkVersion(s.regelwerk.Datenklasse, s.regelwerk.Einstufung, s.regelwerk.Anforderungen)
|
|
|
|
_, err = s.store.CreateBewertung(ctx, in)
|
|
return err
|
|
}
|
|
|
|
// einstufungHerleitungText fügt die Quelle (z. B. "Art. 5 KI-VO") mit
|
|
// an, falls das Regelwerk eine nennt.
|
|
func einstufungHerleitungText(e rules.EinstufungErgebnis) string {
|
|
if e.Quelle == "" {
|
|
return e.Herleitung
|
|
}
|
|
return e.Herleitung + " (" + e.Quelle + ")"
|
|
}
|
|
|
|
type antragListItem struct {
|
|
ID string
|
|
Titel string
|
|
Status string
|
|
Haeufigkeit string
|
|
CreatedAt string
|
|
}
|
|
|
|
type antragListData struct {
|
|
Title string
|
|
Nav navData
|
|
Antraege []antragListItem
|
|
Suche string
|
|
Pagination pagination
|
|
}
|
|
|
|
// handleAntragList zeigt die eigenen Anträge (Ebene 2 — "Eigene
|
|
// Anträge einsehen, Status verfolgen"), nicht die des ganzen Mandanten.
|
|
func (s *Server) handleAntragList(w http.ResponseWriter, r *http.Request) {
|
|
if currentUser(r).Role == "betreiber" {
|
|
http.Redirect(w, r, "/betreiber", http.StatusSeeOther)
|
|
return
|
|
}
|
|
list, err := s.store.ListAntraegeForUser(r.Context(), currentUser(r).ID)
|
|
if err != nil {
|
|
http.Error(w, "Anträge konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
q := strings.TrimSpace(r.URL.Query().Get("q"))
|
|
var gefiltert []store.Antrag
|
|
for _, a := range list {
|
|
if matchesQuery(q, a.Titel, a.Status, a.Haeufigkeit) {
|
|
gefiltert = append(gefiltert, a)
|
|
}
|
|
}
|
|
pag := paginationFromRequest(r, len(gefiltert))
|
|
data := antragListData{Title: "Meine Anträge", Nav: navFor(r), Suche: q, Pagination: pag}
|
|
for _, a := range paginate(gefiltert, pag) {
|
|
data.Antraege = append(data.Antraege, antragListItem{
|
|
ID: a.ID, Titel: a.Titel, Status: a.Status, Haeufigkeit: a.Haeufigkeit,
|
|
CreatedAt: a.CreatedAt.Format("02.01.2006 15:04"),
|
|
})
|
|
}
|
|
if err := s.templates.ExecuteTemplate(w, "antraege-liste", data); err != nil {
|
|
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
|
}
|
|
}
|
|
|
|
type anforderungView struct {
|
|
Beschreibung string
|
|
Herleitung string
|
|
}
|
|
|
|
type werkzeugAusschlussView struct {
|
|
Name string
|
|
NichtErfuellt []string
|
|
}
|
|
|
|
type bewertungView struct {
|
|
Datenklasse string
|
|
DatenklasseHerleitung string
|
|
Einstufung string
|
|
EinstufungHerleitung string
|
|
Verboten bool
|
|
Anforderungen []anforderungView
|
|
ZulaessigeWerkzeuge []string // Namen, nicht IDs
|
|
AusgeschlosseneWerkzeuge []werkzeugAusschlussView
|
|
RegelwerkVersion string
|
|
KatalogVersion string
|
|
CreatedAt string
|
|
}
|
|
|
|
type antragDetailData struct {
|
|
Title string
|
|
Nav navData
|
|
Titel string
|
|
Beschreibung string
|
|
Ergebnis string
|
|
Haeufigkeit string
|
|
Status string
|
|
CreatedAt string
|
|
Antworten []antwortZeile
|
|
Bewertung *bewertungView
|
|
}
|
|
|
|
// handleAntragDetail zeigt einen einzelnen Antrag mit allen Antworten.
|
|
// Ein Antrag eines fremden Nutzers wird wie ein nicht existierender
|
|
// behandelt (404), nie mit einer expliziten 403 bestätigt — dasselbe
|
|
// Muster wie schon beim alten Produkt für fremde Mandanten.
|
|
func (s *Server) handleAntragDetail(w http.ResponseWriter, r *http.Request) {
|
|
antrag, err := s.store.GetAntrag(r.Context(), r.PathValue("id"))
|
|
if err != nil || antrag.ErstellerUserID != currentUser(r).ID {
|
|
http.Error(w, "Antrag nicht gefunden", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
var antworten map[string]any
|
|
if len(antrag.Antworten) > 0 {
|
|
_ = json.Unmarshal(antrag.Antworten, &antworten)
|
|
}
|
|
|
|
data := antragDetailData{
|
|
Title: "Antrag", Nav: navFor(r), Titel: antrag.Titel,
|
|
Beschreibung: antrag.Beschreibung, Ergebnis: antrag.Ergebnis, Haeufigkeit: antrag.Haeufigkeit,
|
|
Status: antrag.Status, CreatedAt: antrag.CreatedAt.Format("02.01.2006 15:04"), Antworten: antwortenAnzeige(antworten),
|
|
}
|
|
|
|
bewertung, err := s.store.GetLatestBewertungForAntrag(r.Context(), antrag.ID)
|
|
if err == nil {
|
|
data.Bewertung = s.toBewertungView(r.Context(), bewertung)
|
|
}
|
|
|
|
if err := s.templates.ExecuteTemplate(w, "antrag-detail", data); err != nil {
|
|
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
|
}
|
|
}
|
|
|
|
// toBewertungView löst die in der Bewertung nur als ID gespeicherten
|
|
// Werkzeuge zu Anzeigenamen auf. Ein Werkzeug, das seit der Berechnung
|
|
// gelöscht wurde, wird mit seiner ID statt eines Namens angezeigt,
|
|
// statt den ganzen Antrag unanzeigbar zu machen.
|
|
func (s *Server) toBewertungView(ctx context.Context, b store.Bewertung) *bewertungView {
|
|
v := &bewertungView{
|
|
Datenklasse: b.Datenklasse, DatenklasseHerleitung: b.DatenklasseHerleitung,
|
|
Einstufung: b.Einstufung, EinstufungHerleitung: b.EinstufungHerleitung, Verboten: b.Verboten,
|
|
RegelwerkVersion: b.RegelwerkVersion, KatalogVersion: b.KatalogVersion,
|
|
CreatedAt: b.CreatedAt.Format("02.01.2006 15:04"),
|
|
}
|
|
for _, a := range b.Anforderungen {
|
|
v.Anforderungen = append(v.Anforderungen, anforderungView{Beschreibung: a.Beschreibung, Herleitung: a.Herleitung})
|
|
}
|
|
for _, id := range b.ZulaessigeWerkzeuge {
|
|
v.ZulaessigeWerkzeuge = append(v.ZulaessigeWerkzeuge, s.werkzeugName(ctx, id))
|
|
}
|
|
for _, a := range b.AusgeschlosseneWerkzeuge {
|
|
v.AusgeschlosseneWerkzeuge = append(v.AusgeschlosseneWerkzeuge, werkzeugAusschlussView{
|
|
Name: s.werkzeugName(ctx, a.WerkzeugID), NichtErfuellt: a.NichtErfuellt,
|
|
})
|
|
}
|
|
return v
|
|
}
|
|
|
|
func (s *Server) werkzeugName(ctx context.Context, id string) string {
|
|
w, err := s.store.GetWerkzeug(ctx, id)
|
|
if err != nil {
|
|
return id
|
|
}
|
|
return w.Name
|
|
}
|