diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d94969b --- /dev/null +++ b/.gitignore @@ -0,0 +1,77 @@ +### Go +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, built with `go test -c` +*.test + +# Output of the go coverage tool +*.out + +# Go workspace file +go.work.sum + +# env file +.env + +### Node +# Dependencies +node_modules/ + +# Logs +*.log + +# Runtime data +*.pid +*.pid.lock + +# Coverage +coverage/ +*.lcov +.nyc_output + +# Build output +dist/ +build/Release + +# TypeScript cache +*.tsbuildinfo + +# Framework build output and caches +.cache +.parcel-cache +.next +out/ +.nuxt + +# dotenv environment variable files +.env +.env.local +.env.*.local + +# npm cache directory +.npm +*.tgz + +# yarn v2 +.yarn/cache +.yarn/unplugged +.yarn/install-state.gz +.pnp.* + +### Windows +# Windows thumbnail cache files +Thumbs.db + +# Folder config file +[Dd]esktop.ini + +# Recycle Bin used on file shares +$RECYCLE.BIN/ + +# Windows shortcuts +*.lnk \ No newline at end of file diff --git a/backend/.env b/backend/.env index 4781d23..8b9a39d 100644 --- a/backend/.env +++ b/backend/.env @@ -2,6 +2,12 @@ PORT=8080 DATABASE_URL=postgres://postgres:Timmy0104199%3F@localhost:5432/teg?sslmode=disable JWT_SECRET=tegvideo7010! FRONTEND_ORIGIN=http://localhost:5173 +ADDRESS_SEARCH_PROVIDER=photon +ADDRESS_SEARCH_URL=https://photon.komoot.io/api +ADDRESS_SEARCH_LANGUAGE=de +ADDRESS_SEARCH_COUNTRY_CODE=de +ADDRESS_SEARCH_LIMIT=5 +ADDRESS_SEARCH_USER_AGENT=TEG-App/1.0 ADMIN_USERNAME=admin ADMIN_DISPLAY_NAME=Administrator diff --git a/backend/address_search.go b/backend/address_search.go new file mode 100644 index 0000000..e295988 --- /dev/null +++ b/backend/address_search.go @@ -0,0 +1,420 @@ +// backend\address_search.go + +package main + +import ( + "encoding/json" + "net/http" + "net/url" + "os" + "strconv" + "strings" + "time" +) + +type AddressSuggestion struct { + ID string `json:"id"` + Label string `json:"label"` + Lat float64 `json:"lat"` + Lon float64 `json:"lon"` + Type string `json:"type"` +} + +type photonResponse struct { + Features []photonFeature `json:"features"` +} + +type photonFeature struct { + Properties photonProperties `json:"properties"` + Geometry photonGeometry `json:"geometry"` +} + +type photonProperties struct { + OSMID int64 `json:"osm_id"` + OSMType string `json:"osm_type"` + OSMValue string `json:"osm_value"` + Name string `json:"name"` + Street string `json:"street"` + HouseNumber string `json:"housenumber"` + Postcode string `json:"postcode"` + City string `json:"city"` + District string `json:"district"` + State string `json:"state"` + Country string `json:"country"` + CountryCode string `json:"countrycode"` +} + +type photonGeometry struct { + Coordinates []float64 `json:"coordinates"` +} + +type nominatimSearchResult struct { + PlaceID int64 `json:"place_id"` + DisplayName string `json:"display_name"` + Lat string `json:"lat"` + Lon string `json:"lon"` + Type string `json:"type"` +} + +func (s *Server) handleAddressSearch(w http.ResponseWriter, r *http.Request) { + query := strings.TrimSpace(r.URL.Query().Get("q")) + + if len([]rune(query)) < 3 { + writeJSON(w, http.StatusOK, map[string]any{ + "suggestions": []AddressSuggestion{}, + }) + return + } + + searchURL := strings.TrimSpace(os.Getenv("ADDRESS_SEARCH_URL")) + if searchURL == "" { + writeError(w, http.StatusNotImplemented, "Adresssuche ist nicht konfiguriert") + return + } + + provider := strings.ToLower(strings.TrimSpace(os.Getenv("ADDRESS_SEARCH_PROVIDER"))) + if provider == "" { + provider = detectAddressSearchProvider(searchURL) + } + + switch provider { + case "photon": + s.handlePhotonAddressSearch(w, r, searchURL, query) + case "nominatim": + s.handleNominatimAddressSearch(w, r, searchURL, query) + default: + writeError(w, http.StatusInternalServerError, "Adresssuche-Provider ist unbekannt") + } +} + +func (s *Server) handlePhotonAddressSearch( + w http.ResponseWriter, + r *http.Request, + searchURL string, + query string, +) { + parsedURL, err := url.Parse(searchURL) + if err != nil { + writeError(w, http.StatusInternalServerError, "Adresssuche ist falsch konfiguriert") + return + } + + limit := getAddressSearchLimit() + language := envOrDefault("ADDRESS_SEARCH_LANGUAGE", "de") + countryCode := strings.ToLower(strings.TrimSpace(envOrDefault("ADDRESS_SEARCH_COUNTRY_CODE", "de"))) + + values := parsedURL.Query() + values.Set("q", query) + values.Set("limit", strconv.Itoa(limit)) + + if language != "" { + values.Set("lang", language) + } + + parsedURL.RawQuery = values.Encode() + + request, err := http.NewRequestWithContext(r.Context(), http.MethodGet, parsedURL.String(), nil) + if err != nil { + writeError(w, http.StatusInternalServerError, "Adresssuche konnte nicht vorbereitet werden") + return + } + + request.Header.Set("Accept", "application/json") + request.Header.Set("User-Agent", addressSearchUserAgent()) + + response, err := httpClient().Do(request) + if err != nil { + writeError(w, http.StatusBadGateway, "Adresssuche konnte nicht geladen werden") + return + } + defer response.Body.Close() + + if response.StatusCode < 200 || response.StatusCode >= 300 { + writeError(w, http.StatusBadGateway, "Adresssuche konnte nicht geladen werden") + return + } + + var result photonResponse + + if err := json.NewDecoder(response.Body).Decode(&result); err != nil { + writeError(w, http.StatusBadGateway, "Adresssuche konnte nicht gelesen werden") + return + } + + suggestions := make([]AddressSuggestion, 0, len(result.Features)) + + for _, feature := range result.Features { + if len(feature.Geometry.Coordinates) < 2 { + continue + } + + if countryCode != "" && + feature.Properties.CountryCode != "" && + strings.ToLower(feature.Properties.CountryCode) != countryCode { + continue + } + + lon := feature.Geometry.Coordinates[0] + lat := feature.Geometry.Coordinates[1] + + label := buildPhotonLabel(feature.Properties) + if label == "" { + continue + } + + suggestions = append(suggestions, AddressSuggestion{ + ID: buildPhotonID(feature.Properties, label), + Label: label, + Lat: lat, + Lon: lon, + Type: buildPhotonType(feature.Properties), + }) + } + + writeJSON(w, http.StatusOK, map[string]any{ + "suggestions": suggestions, + }) +} + +func (s *Server) handleNominatimAddressSearch( + w http.ResponseWriter, + r *http.Request, + searchURL string, + query string, +) { + parsedURL, err := url.Parse(searchURL) + if err != nil { + writeError(w, http.StatusInternalServerError, "Adresssuche ist falsch konfiguriert") + return + } + + limit := getAddressSearchLimit() + countryCode := strings.ToLower(strings.TrimSpace(envOrDefault("ADDRESS_SEARCH_COUNTRY_CODE", "de"))) + + values := parsedURL.Query() + values.Set("q", query) + values.Set("format", "jsonv2") + values.Set("addressdetails", "1") + values.Set("limit", strconv.Itoa(limit)) + + if countryCode != "" && values.Get("countrycodes") == "" { + values.Set("countrycodes", countryCode) + } + + parsedURL.RawQuery = values.Encode() + + request, err := http.NewRequestWithContext(r.Context(), http.MethodGet, parsedURL.String(), nil) + if err != nil { + writeError(w, http.StatusInternalServerError, "Adresssuche konnte nicht vorbereitet werden") + return + } + + request.Header.Set("Accept", "application/json") + request.Header.Set("User-Agent", addressSearchUserAgent()) + + response, err := httpClient().Do(request) + if err != nil { + writeError(w, http.StatusBadGateway, "Adresssuche konnte nicht geladen werden") + return + } + defer response.Body.Close() + + if response.StatusCode < 200 || response.StatusCode >= 300 { + writeError(w, http.StatusBadGateway, "Adresssuche konnte nicht geladen werden") + return + } + + var results []nominatimSearchResult + + if err := json.NewDecoder(response.Body).Decode(&results); err != nil { + writeError(w, http.StatusBadGateway, "Adresssuche konnte nicht gelesen werden") + return + } + + suggestions := make([]AddressSuggestion, 0, len(results)) + + for _, result := range results { + label := strings.TrimSpace(result.DisplayName) + if label == "" { + continue + } + + lat, latErr := strconv.ParseFloat(result.Lat, 64) + lon, lonErr := strconv.ParseFloat(result.Lon, 64) + + if latErr != nil || lonErr != nil { + continue + } + + suggestions = append(suggestions, AddressSuggestion{ + ID: strconv.FormatInt(result.PlaceID, 10), + Label: label, + Lat: lat, + Lon: lon, + Type: result.Type, + }) + } + + writeJSON(w, http.StatusOK, map[string]any{ + "suggestions": suggestions, + }) +} + +func buildPhotonLabel(properties photonProperties) string { + parts := []string{} + + streetLine := strings.TrimSpace( + strings.Join( + []string{ + strings.TrimSpace(properties.Street), + strings.TrimSpace(properties.HouseNumber), + }, + " ", + ), + ) + + if streetLine != "" { + parts = append(parts, streetLine) + } else if strings.TrimSpace(properties.Name) != "" { + parts = append(parts, strings.TrimSpace(properties.Name)) + } + + cityLine := strings.TrimSpace( + strings.Join( + []string{ + strings.TrimSpace(properties.Postcode), + firstNonEmpty(properties.City, properties.District), + }, + " ", + ), + ) + + if cityLine != "" { + parts = append(parts, cityLine) + } + + if strings.TrimSpace(properties.State) != "" { + parts = append(parts, strings.TrimSpace(properties.State)) + } + + if strings.TrimSpace(properties.Country) != "" { + parts = append(parts, strings.TrimSpace(properties.Country)) + } + + return strings.Join(uniqueNonEmpty(parts), ", ") +} + +func buildPhotonID(properties photonProperties, fallback string) string { + if properties.OSMID != 0 { + if properties.OSMType != "" { + return properties.OSMType + ":" + strconv.FormatInt(properties.OSMID, 10) + } + + return strconv.FormatInt(properties.OSMID, 10) + } + + return fallback +} + +func buildPhotonType(properties photonProperties) string { + if strings.TrimSpace(properties.OSMValue) != "" { + return strings.TrimSpace(properties.OSMValue) + } + + if strings.TrimSpace(properties.OSMType) != "" { + return strings.TrimSpace(properties.OSMType) + } + + return "Adresse" +} + +func detectAddressSearchProvider(searchURL string) string { + lowerURL := strings.ToLower(searchURL) + + if strings.Contains(lowerURL, "photon") { + return "photon" + } + + if strings.Contains(lowerURL, "nominatim") { + return "nominatim" + } + + return "photon" +} + +func getAddressSearchLimit() int { + rawLimit := strings.TrimSpace(os.Getenv("ADDRESS_SEARCH_LIMIT")) + if rawLimit == "" { + return 5 + } + + limit, err := strconv.Atoi(rawLimit) + if err != nil { + return 5 + } + + if limit < 1 { + return 1 + } + + if limit > 10 { + return 10 + } + + return limit +} + +func addressSearchUserAgent() string { + return envOrDefault("ADDRESS_SEARCH_USER_AGENT", "TEG-App/1.0") +} + +func httpClient() *http.Client { + return &http.Client{ + Timeout: 8 * time.Second, + } +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + value = strings.TrimSpace(value) + + if value != "" { + return value + } + } + + return "" +} + +func uniqueNonEmpty(values []string) []string { + seen := map[string]bool{} + result := []string{} + + for _, value := range values { + value = strings.TrimSpace(value) + + if value == "" { + continue + } + + key := strings.ToLower(value) + if seen[key] { + continue + } + + seen[key] = true + result = append(result, value) + } + + return result +} + +func envOrDefault(key string, fallback string) string { + value := strings.TrimSpace(os.Getenv(key)) + + if value == "" { + return fallback + } + + return value +} diff --git a/backend/admin.go b/backend/admin.go new file mode 100644 index 0000000..d5b02a8 --- /dev/null +++ b/backend/admin.go @@ -0,0 +1,557 @@ +// backend/admin.go + +package main + +import ( + "context" + "errors" + "net/http" + "strings" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" + "golang.org/x/crypto/bcrypt" +) + +type AdminTeam struct { + ID string `json:"id"` + Name string `json:"name"` + Initial string `json:"initial"` + Href string `json:"href"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +type AdminUser struct { + ID string `json:"id"` + Username string `json:"username"` + DisplayName string `json:"displayName"` + Email string `json:"email"` + Avatar string `json:"avatar"` + Unit string `json:"unit"` + Group string `json:"group"` + Rights []string `json:"rights"` + Teams []AdminTeam `json:"teams"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +type AdminUserRequest struct { + Username string `json:"username"` + DisplayName string `json:"displayName"` + Email string `json:"email"` + Password string `json:"password"` + Avatar string `json:"avatar"` + Unit string `json:"unit"` + Group string `json:"group"` + Rights []string `json:"rights"` + TeamIDs []string `json:"teamIds"` +} + +type AdminTeamRequest struct { + Name string `json:"name"` + Initial string `json:"initial"` + Href string `json:"href"` +} + +func normalizeAdminUserInput(input *AdminUserRequest) { + input.Username = strings.TrimSpace(input.Username) + input.DisplayName = strings.TrimSpace(input.DisplayName) + input.Email = strings.TrimSpace(input.Email) + input.Password = strings.TrimSpace(input.Password) + input.Avatar = strings.TrimSpace(input.Avatar) + input.Unit = strings.TrimSpace(input.Unit) + input.Group = strings.TrimSpace(input.Group) + + if input.Group == "" { + input.Group = "user" + } + + input.Rights = cleanStringList(input.Rights) + input.TeamIDs = cleanStringList(input.TeamIDs) +} + +func normalizeAdminTeamInput(input *AdminTeamRequest) { + input.Name = strings.TrimSpace(input.Name) + input.Initial = strings.TrimSpace(input.Initial) + input.Href = strings.TrimSpace(input.Href) + + if input.Initial == "" && input.Name != "" { + input.Initial = strings.ToUpper(string([]rune(input.Name)[0])) + } + + if input.Href == "" { + input.Href = "#" + } +} + +func cleanStringList(values []string) []string { + result := []string{} + seen := map[string]bool{} + + for _, value := range values { + value = strings.TrimSpace(value) + if value == "" || seen[value] { + continue + } + + seen[value] = true + result = append(result, value) + } + + return result +} + +func (s *Server) handleAdminListUsers(w http.ResponseWriter, r *http.Request) { + rows, err := s.db.Query( + r.Context(), + ` + SELECT + id::TEXT, + COALESCE(username, ''), + COALESCE(display_name, ''), + email, + COALESCE(avatar, ''), + COALESCE(unit, ''), + COALESCE(user_group, ''), + rights, + created_at, + updated_at + FROM users + ORDER BY display_name ASC, email ASC + `, + ) + + if err != nil { + writeError(w, http.StatusInternalServerError, "Benutzer konnten nicht geladen werden") + return + } + defer rows.Close() + + users := []AdminUser{} + userByID := map[string]*AdminUser{} + + for rows.Next() { + var user AdminUser + + if err := rows.Scan( + &user.ID, + &user.Username, + &user.DisplayName, + &user.Email, + &user.Avatar, + &user.Unit, + &user.Group, + &user.Rights, + &user.CreatedAt, + &user.UpdatedAt, + ); err != nil { + writeError(w, http.StatusInternalServerError, "Benutzer konnten nicht gelesen werden") + return + } + + user.Teams = []AdminTeam{} + users = append(users, user) + userByID[user.ID] = &users[len(users)-1] + } + + teamRows, err := s.db.Query( + r.Context(), + ` + SELECT + ut.user_id::TEXT, + t.id::TEXT, + t.name, + t.initial, + t.href, + t.created_at, + t.updated_at + FROM user_teams ut + JOIN teams t ON t.id = ut.team_id + ORDER BY t.name ASC + `, + ) + + if err != nil { + writeError(w, http.StatusInternalServerError, "Team-Zuordnungen konnten nicht geladen werden") + return + } + defer teamRows.Close() + + for teamRows.Next() { + var userID string + var team AdminTeam + + if err := teamRows.Scan( + &userID, + &team.ID, + &team.Name, + &team.Initial, + &team.Href, + &team.CreatedAt, + &team.UpdatedAt, + ); err != nil { + writeError(w, http.StatusInternalServerError, "Team-Zuordnungen konnten nicht gelesen werden") + return + } + + if user := userByID[userID]; user != nil { + user.Teams = append(user.Teams, team) + } + } + + writeJSON(w, http.StatusOK, map[string]any{ + "users": users, + }) +} + +func (s *Server) handleAdminCreateUser(w http.ResponseWriter, r *http.Request) { + var input AdminUserRequest + + if err := readJSON(r, &input); err != nil { + writeError(w, http.StatusBadRequest, "Invalid JSON") + return + } + + normalizeAdminUserInput(&input) + + if input.Email == "" { + writeError(w, http.StatusBadRequest, "E-Mail ist erforderlich") + return + } + + if input.Password == "" { + writeError(w, http.StatusBadRequest, "Passwort ist erforderlich") + return + } + + passwordHash, err := bcrypt.GenerateFromPassword([]byte(input.Password), bcrypt.DefaultCost) + if err != nil { + writeError(w, http.StatusInternalServerError, "Passwort konnte nicht verarbeitet werden") + return + } + + tx, err := s.db.Begin(r.Context()) + if err != nil { + writeError(w, http.StatusInternalServerError, "Benutzer konnte nicht erstellt werden") + return + } + defer tx.Rollback(r.Context()) + + var userID string + + err = tx.QueryRow( + r.Context(), + ` + INSERT INTO users ( + username, + display_name, + email, + password_hash, + avatar, + unit, + user_group, + rights + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + RETURNING id::TEXT + `, + input.Username, + input.DisplayName, + input.Email, + string(passwordHash), + input.Avatar, + input.Unit, + input.Group, + input.Rights, + ).Scan(&userID) + + if err != nil { + var pgErr *pgconn.PgError + + if errors.As(err, &pgErr) && pgErr.Code == "23505" { + writeError(w, http.StatusConflict, "Benutzername oder E-Mail existiert bereits") + return + } + + writeError(w, http.StatusInternalServerError, "Benutzer konnte nicht erstellt werden") + return + } + + if err := replaceUserTeams(r.Context(), tx, userID, input.TeamIDs); err != nil { + writeError(w, http.StatusInternalServerError, "Teams konnten nicht zugewiesen werden") + return + } + + if err := tx.Commit(r.Context()); err != nil { + writeError(w, http.StatusInternalServerError, "Benutzer konnte nicht erstellt werden") + return + } + + writeJSON(w, http.StatusCreated, map[string]any{ + "ok": true, + }) +} + +func (s *Server) handleAdminUpdateUser(w http.ResponseWriter, r *http.Request) { + userID := strings.TrimSpace(r.PathValue("id")) + + if userID == "" { + writeError(w, http.StatusBadRequest, "Benutzer-ID fehlt") + return + } + + var input AdminUserRequest + + if err := readJSON(r, &input); err != nil { + writeError(w, http.StatusBadRequest, "Invalid JSON") + return + } + + normalizeAdminUserInput(&input) + + if input.Email == "" { + writeError(w, http.StatusBadRequest, "E-Mail ist erforderlich") + return + } + + tx, err := s.db.Begin(r.Context()) + if err != nil { + writeError(w, http.StatusInternalServerError, "Benutzer konnte nicht aktualisiert werden") + return + } + defer tx.Rollback(r.Context()) + + if input.Password != "" { + passwordHash, err := bcrypt.GenerateFromPassword([]byte(input.Password), bcrypt.DefaultCost) + if err != nil { + writeError(w, http.StatusInternalServerError, "Passwort konnte nicht verarbeitet werden") + return + } + + _, err = tx.Exec( + r.Context(), + ` + UPDATE users + SET + username = $2, + display_name = $3, + email = $4, + password_hash = $5, + avatar = $6, + unit = $7, + user_group = $8, + rights = $9, + updated_at = now() + WHERE id = $1 + `, + userID, + input.Username, + input.DisplayName, + input.Email, + string(passwordHash), + input.Avatar, + input.Unit, + input.Group, + input.Rights, + ) + + if err != nil { + writeError(w, http.StatusInternalServerError, "Benutzer konnte nicht aktualisiert werden") + return + } + } else { + _, err = tx.Exec( + r.Context(), + ` + UPDATE users + SET + username = $2, + display_name = $3, + email = $4, + avatar = $5, + unit = $6, + user_group = $7, + rights = $8, + updated_at = now() + WHERE id = $1 + `, + userID, + input.Username, + input.DisplayName, + input.Email, + input.Avatar, + input.Unit, + input.Group, + input.Rights, + ) + + if err != nil { + writeError(w, http.StatusInternalServerError, "Benutzer konnte nicht aktualisiert werden") + return + } + } + + if err := replaceUserTeams(r.Context(), tx, userID, input.TeamIDs); err != nil { + writeError(w, http.StatusInternalServerError, "Teams konnten nicht zugewiesen werden") + return + } + + if err := tx.Commit(r.Context()); err != nil { + writeError(w, http.StatusInternalServerError, "Benutzer konnte nicht aktualisiert werden") + return + } + + writeJSON(w, http.StatusOK, map[string]any{ + "ok": true, + }) +} + +func replaceUserTeams(ctx context.Context, tx pgx.Tx, userID string, teamIDs []string) error { + if _, err := tx.Exec(ctx, `DELETE FROM user_teams WHERE user_id = $1`, userID); err != nil { + return err + } + + for _, teamID := range teamIDs { + if _, err := tx.Exec( + ctx, + ` + INSERT INTO user_teams (user_id, team_id) + VALUES ($1, $2) + ON CONFLICT (user_id, team_id) DO NOTHING + `, + userID, + teamID, + ); err != nil { + return err + } + } + + return nil +} + +func (s *Server) handleAdminListTeams(w http.ResponseWriter, r *http.Request) { + rows, err := s.db.Query( + r.Context(), + ` + SELECT id::TEXT, name, initial, href, created_at, updated_at + FROM teams + ORDER BY name ASC + `, + ) + + if err != nil { + writeError(w, http.StatusInternalServerError, "Teams konnten nicht geladen werden") + return + } + defer rows.Close() + + teams := []AdminTeam{} + + for rows.Next() { + var team AdminTeam + + if err := rows.Scan( + &team.ID, + &team.Name, + &team.Initial, + &team.Href, + &team.CreatedAt, + &team.UpdatedAt, + ); err != nil { + writeError(w, http.StatusInternalServerError, "Teams konnten nicht gelesen werden") + return + } + + teams = append(teams, team) + } + + writeJSON(w, http.StatusOK, map[string]any{ + "teams": teams, + }) +} + +func (s *Server) handleAdminCreateTeam(w http.ResponseWriter, r *http.Request) { + var input AdminTeamRequest + + if err := readJSON(r, &input); err != nil { + writeError(w, http.StatusBadRequest, "Invalid JSON") + return + } + + normalizeAdminTeamInput(&input) + + if input.Name == "" { + writeError(w, http.StatusBadRequest, "Teamname ist erforderlich") + return + } + + _, err := s.db.Exec( + r.Context(), + ` + INSERT INTO teams (name, initial, href) + VALUES ($1, $2, $3) + `, + input.Name, + input.Initial, + input.Href, + ) + + if err != nil { + writeError(w, http.StatusInternalServerError, "Team konnte nicht erstellt werden") + return + } + + writeJSON(w, http.StatusCreated, map[string]any{ + "ok": true, + }) +} + +func (s *Server) handleAdminUpdateTeam(w http.ResponseWriter, r *http.Request) { + teamID := strings.TrimSpace(r.PathValue("id")) + + if teamID == "" { + writeError(w, http.StatusBadRequest, "Team-ID fehlt") + return + } + + var input AdminTeamRequest + + if err := readJSON(r, &input); err != nil { + writeError(w, http.StatusBadRequest, "Invalid JSON") + return + } + + normalizeAdminTeamInput(&input) + + if input.Name == "" { + writeError(w, http.StatusBadRequest, "Teamname ist erforderlich") + return + } + + _, err := s.db.Exec( + r.Context(), + ` + UPDATE teams + SET name = $2, + initial = $3, + href = $4, + updated_at = now() + WHERE id = $1 + `, + teamID, + input.Name, + input.Initial, + input.Href, + ) + + if err != nil { + writeError(w, http.StatusInternalServerError, "Team konnte nicht aktualisiert werden") + return + } + + writeJSON(w, http.StatusOK, map[string]any{ + "ok": true, + }) +} diff --git a/backend/admin_milestone.go b/backend/admin_milestone.go new file mode 100644 index 0000000..ca3d3bb --- /dev/null +++ b/backend/admin_milestone.go @@ -0,0 +1,643 @@ +// backend\admin_milestone.go + +package main + +import ( + "context" + "crypto/tls" + "encoding/json" + "errors" + "fmt" + "io" + "log" + "net/http" + "net/url" + "os" + "strings" + "time" + + "github.com/jackc/pgx/v5" +) + +type milestoneSettingsResponse struct { + Host string `json:"host"` + Username string `json:"username"` + PasswordConfigured bool `json:"passwordConfigured"` + SkipTLSVerify bool `json:"skipTlsVerify"` + + TokenConfigured bool `json:"tokenConfigured"` + TokenType string `json:"tokenType"` + TokenExpiresAt *time.Time `json:"tokenExpiresAt,omitempty"` + TokenUpdatedAt *time.Time `json:"tokenUpdatedAt,omitempty"` + + ServerID string `json:"serverId"` + ServerName string `json:"serverName"` + ServerVersion string `json:"serverVersion"` + ServerUpdatedAt *time.Time `json:"serverUpdatedAt,omitempty"` + + UpdatedAt *time.Time `json:"updatedAt,omitempty"` +} + +type updateMilestoneSettingsRequest struct { + Host string `json:"host"` + Username string `json:"username"` + Password string `json:"password"` + SkipTLSVerify bool `json:"skipTlsVerify"` +} + +type milestoneCredentials struct { + Host string + Username string + Password string + SkipTLSVerify bool +} + +type milestoneTokenResponse struct { + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + ExpiresIn int `json:"expires_in"` +} + +type milestoneRecordingServersResponse struct { + Array []milestoneRecordingServer `json:"array"` +} + +type milestoneRecordingServer struct { + ID string `json:"id"` + Name string `json:"name"` + DisplayName string `json:"displayName"` + Version string `json:"version"` + ProductVersion string `json:"productVersion"` + RecordingServerVersion string `json:"recordingServerVersion"` +} + +func (s *Server) handleGetMilestoneSettings(w http.ResponseWriter, r *http.Request) { + settings, err := s.getMilestoneSettings(r.Context()) + if errors.Is(err, pgx.ErrNoRows) { + writeJSON(w, http.StatusOK, map[string]any{ + "settings": milestoneSettingsResponse{ + Host: "", + Username: "", + PasswordConfigured: false, + SkipTLSVerify: false, + TokenConfigured: false, + }, + }) + return + } + + if err != nil { + writeError(w, http.StatusInternalServerError, "Milestone-Einstellungen konnten nicht geladen werden") + return + } + + writeJSON(w, http.StatusOK, map[string]any{ + "settings": settings, + }) +} + +func (s *Server) handleUpdateMilestoneSettings(w http.ResponseWriter, r *http.Request) { + var input updateMilestoneSettingsRequest + + if err := readJSON(r, &input); err != nil { + writeError(w, http.StatusBadRequest, "Invalid JSON") + return + } + + host := normalizeMilestoneHost(input.Host) + username := strings.TrimSpace(input.Username) + password := strings.TrimSpace(input.Password) + skipTLSVerify := input.SkipTLSVerify + + if host == "" { + writeError(w, http.StatusBadRequest, "IP-Adresse oder Hostname ist erforderlich") + return + } + + if username == "" { + writeError(w, http.StatusBadRequest, "Benutzername ist erforderlich") + return + } + + currentSettings, err := s.getMilestoneSettings(r.Context()) + passwordAlreadyConfigured := false + + if err == nil { + passwordAlreadyConfigured = currentSettings.PasswordConfigured + } else if !errors.Is(err, pgx.ErrNoRows) { + writeError(w, http.StatusInternalServerError, "Milestone-Einstellungen konnten nicht geprüft werden") + return + } + + if password == "" && !passwordAlreadyConfigured { + writeError(w, http.StatusBadRequest, "Kennwort ist erforderlich") + return + } + + encryptionKey := s.milestoneEncryptionKey() + + if password != "" { + _, err = s.db.Exec( + r.Context(), + ` + INSERT INTO milestone_settings ( + id, + host, + username, + password_encrypted, + skip_tls_verify, + access_token_encrypted, + token_type, + token_expires_at, + token_updated_at + ) + VALUES ( + 1, + $1, + $2, + pgp_sym_encrypt($3, $4), + $5, + NULL, + '', + NULL, + NULL + ) + ON CONFLICT (id) + DO UPDATE SET + host = EXCLUDED.host, + username = EXCLUDED.username, + password_encrypted = EXCLUDED.password_encrypted, + skip_tls_verify = EXCLUDED.skip_tls_verify, + access_token_encrypted = NULL, + token_type = '', + token_expires_at = NULL, + token_updated_at = NULL, + updated_at = now() + `, + host, + username, + password, + encryptionKey, + skipTLSVerify, + ) + } else { + _, err = s.db.Exec( + r.Context(), + ` + UPDATE milestone_settings + SET + host = $1, + username = $2, + skip_tls_verify = $3, + access_token_encrypted = NULL, + token_type = '', + token_expires_at = NULL, + token_updated_at = NULL, + updated_at = now() + WHERE id = 1 + `, + host, + username, + skipTLSVerify, + ) + } + + if err != nil { + writeError(w, http.StatusInternalServerError, "Milestone-Einstellungen konnten nicht gespeichert werden") + return + } + + syncWarning := "" + + credentials, err := s.getMilestoneCredentials(r.Context()) + if err == nil && credentials.Host != "" && credentials.Username != "" && credentials.Password != "" { + if err := s.refreshMilestoneTokenAndServerID(r.Context(), credentials); err != nil { + log.Printf("Milestone sync after settings save failed: %v", err) + + syncWarning = "Milestone-Zugangsdaten wurden gespeichert, aber Token oder Server-ID konnten nicht abgerufen werden." + } + } else { + syncWarning = "Milestone-Zugangsdaten wurden gespeichert, konnten aber nicht vollständig geprüft werden." + } + + settings, err := s.getMilestoneSettings(r.Context()) + if err != nil { + writeError(w, http.StatusInternalServerError, "Milestone-Einstellungen wurden gespeichert, konnten aber nicht neu geladen werden") + return + } + + response := map[string]any{ + "settings": settings, + } + + if syncWarning != "" { + response["warning"] = syncWarning + } + + writeJSON(w, http.StatusOK, response) +} + +func (s *Server) handleFetchMilestoneToken(w http.ResponseWriter, r *http.Request) { + credentials, err := s.getMilestoneCredentials(r.Context()) + if errors.Is(err, pgx.ErrNoRows) { + writeError(w, http.StatusBadRequest, "Milestone-Einstellungen sind noch nicht hinterlegt") + return + } + + if err != nil { + writeError(w, http.StatusInternalServerError, "Milestone-Zugangsdaten konnten nicht geladen werden") + return + } + + if credentials.Host == "" || credentials.Username == "" || credentials.Password == "" { + writeError(w, http.StatusBadRequest, "Milestone-Zugangsdaten sind unvollständig") + return + } + + if err := s.refreshMilestoneTokenAndServerID(r.Context(), credentials); err != nil { + log.Printf("Milestone token/server sync failed: %v", err) + + writeError(w, http.StatusBadGateway, "Milestone-Token oder Server-ID konnten nicht abgerufen werden") + return + } + + settings, err := s.getMilestoneSettings(r.Context()) + if err != nil { + writeError(w, http.StatusInternalServerError, "Milestone-Daten wurden gespeichert, konnten aber nicht neu geladen werden") + return + } + + writeJSON(w, http.StatusOK, map[string]any{ + "settings": settings, + }) +} + +func (s *Server) getMilestoneSettings(ctx context.Context) (milestoneSettingsResponse, error) { + var settings milestoneSettingsResponse + var updatedAt time.Time + var tokenExpiresAt *time.Time + var tokenUpdatedAt *time.Time + var serverUpdatedAt *time.Time + + err := s.db.QueryRow( + ctx, + ` + SELECT + host, + username, + password_encrypted IS NOT NULL, + skip_tls_verify, + access_token_encrypted IS NOT NULL, + token_type, + token_expires_at, + token_updated_at, + COALESCE(server_id, ''), + COALESCE(server_name, ''), + COALESCE(server_version, ''), + server_updated_at, + updated_at + FROM milestone_settings + WHERE id = 1 + LIMIT 1 + `, + ).Scan( + &settings.Host, + &settings.Username, + &settings.PasswordConfigured, + &settings.SkipTLSVerify, + &settings.TokenConfigured, + &settings.TokenType, + &tokenExpiresAt, + &tokenUpdatedAt, + &settings.ServerID, + &settings.ServerName, + &settings.ServerVersion, + &serverUpdatedAt, + &updatedAt, + ) + + if err != nil { + return settings, err + } + + settings.TokenExpiresAt = tokenExpiresAt + settings.TokenUpdatedAt = tokenUpdatedAt + settings.ServerUpdatedAt = serverUpdatedAt + settings.UpdatedAt = &updatedAt + + return settings, nil +} + +func (s *Server) refreshMilestoneTokenAndServerID( + ctx context.Context, + credentials milestoneCredentials, +) error { + tokenResponse, err := s.requestMilestoneToken(ctx, credentials) + if err != nil { + return err + } + + accessToken := strings.TrimSpace(tokenResponse.AccessToken) + if accessToken == "" { + return errors.New("milestone returned empty access token") + } + + tokenType := strings.TrimSpace(tokenResponse.TokenType) + if tokenType == "" { + tokenType = "Bearer" + } + + expiresIn := tokenResponse.ExpiresIn + if expiresIn <= 0 { + expiresIn = 3600 + } + + tokenExpiresAt := time.Now().Add(time.Duration(expiresIn) * time.Second) + + serverID, serverName, serverVersion, err := s.requestMilestoneServerID( + ctx, + credentials.Host, + tokenType, + accessToken, + credentials.SkipTLSVerify, + ) + + if err != nil { + return err + } + + encryptionKey := s.milestoneEncryptionKey() + + _, err = s.db.Exec( + ctx, + ` + UPDATE milestone_settings + SET + access_token_encrypted = pgp_sym_encrypt($1, $2), + token_type = $3, + token_expires_at = $4, + token_updated_at = now(), + server_id = $5, + server_name = $6, + server_version = $7, + server_updated_at = now(), + updated_at = now() + WHERE id = 1 + `, + accessToken, + encryptionKey, + tokenType, + tokenExpiresAt, + serverID, + serverName, + serverVersion, + ) + + return err +} + +func (s *Server) requestMilestoneServerID( + ctx context.Context, + host string, + tokenType string, + accessToken string, + skipTLSVerify bool, +) (string, string, string, error) { + requestURL := strings.TrimRight(host, "/") + "/API/rest/v1/recordingServers" + + log.Printf( + "Requesting Milestone recording server id: url=%s skipTLSVerify=%t", + requestURL, + skipTLSVerify, + ) + + request, err := http.NewRequestWithContext( + ctx, + http.MethodGet, + requestURL, + nil, + ) + if err != nil { + return "", "", "", err + } + + request.Header.Set("Authorization", "Bearer "+accessToken) + request.Header.Set("Accept", "application/json") + + client := milestoneHTTPClient(skipTLSVerify) + + response, err := client.Do(request) + if err != nil { + return "", "", "", err + } + defer response.Body.Close() + + body, err := io.ReadAll(response.Body) + if err != nil { + return "", "", "", err + } + + if response.StatusCode < 200 || response.StatusCode >= 300 { + return "", "", "", fmt.Errorf( + "milestone recording servers request failed: url=%s status=%d body=%s", + requestURL, + response.StatusCode, + truncateMilestoneLogBody(body), + ) + } + + var recordingServersResponse milestoneRecordingServersResponse + if err := json.Unmarshal(body, &recordingServersResponse); err != nil { + return "", "", "", fmt.Errorf( + "milestone recording servers response could not be parsed: body=%s error=%w", + truncateMilestoneLogBody(body), + err, + ) + } + + if len(recordingServersResponse.Array) == 0 { + return "", "", "", fmt.Errorf( + "milestone returned no recording servers: body=%s", + truncateMilestoneLogBody(body), + ) + } + + recordingServer := recordingServersResponse.Array[0] + + recordingServerID := strings.TrimSpace(recordingServer.ID) + recordingServerName := strings.TrimSpace(recordingServer.DisplayName) + + if recordingServerName == "" { + recordingServerName = strings.TrimSpace(recordingServer.Name) + } + + recordingServerVersion := strings.TrimSpace(recordingServer.Version) + + if recordingServerVersion == "" { + recordingServerVersion = strings.TrimSpace(recordingServer.ProductVersion) + } + + if recordingServerVersion == "" { + recordingServerVersion = strings.TrimSpace(recordingServer.RecordingServerVersion) + } + + if recordingServerID == "" { + return "", "", "", fmt.Errorf( + "milestone returned recording server without id: body=%s", + truncateMilestoneLogBody(body), + ) + } + + log.Printf( + "Milestone recording server received successfully: id=%s displayName=%q version=%q", + recordingServerID, + recordingServerName, + recordingServerVersion, + ) + + return recordingServerID, recordingServerName, recordingServerVersion, nil +} + +func (s *Server) getMilestoneCredentials(ctx context.Context) (milestoneCredentials, error) { + var credentials milestoneCredentials + + err := s.db.QueryRow( + ctx, + ` + SELECT + host, + username, + COALESCE(pgp_sym_decrypt(password_encrypted, $1), ''), + skip_tls_verify + FROM milestone_settings + WHERE id = 1 + LIMIT 1 + `, + s.milestoneEncryptionKey(), + ).Scan( + &credentials.Host, + &credentials.Username, + &credentials.Password, + &credentials.SkipTLSVerify, + ) + + return credentials, err +} + +func milestoneHTTPClient(skipTLSVerify bool) *http.Client { + client := &http.Client{ + Timeout: 15 * time.Second, + } + + if skipTLSVerify { + client.Transport = &http.Transport{ + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: true, + }, + } + } + + return client +} + +func (s *Server) requestMilestoneToken(ctx context.Context, credentials milestoneCredentials) (milestoneTokenResponse, error) { + var tokenResponse milestoneTokenResponse + + form := url.Values{} + form.Set("grant_type", "password") + form.Set("username", credentials.Username) + form.Set("password", credentials.Password) + form.Set("client_id", "GrantValidatorClient") + + requestURL := strings.TrimRight(credentials.Host, "/") + "/API/IDP/connect/token" + + log.Printf("Requesting Milestone token: url=%s username=%s", requestURL, credentials.Username) + + request, err := http.NewRequestWithContext( + ctx, + http.MethodPost, + requestURL, + strings.NewReader(form.Encode()), + ) + if err != nil { + return tokenResponse, err + } + + request.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + client := milestoneHTTPClient(credentials.SkipTLSVerify) + + response, err := client.Do(request) + if err != nil { + return tokenResponse, err + } + defer response.Body.Close() + + body, err := io.ReadAll(response.Body) + if err != nil { + return tokenResponse, err + } + + if response.StatusCode < 200 || response.StatusCode >= 300 { + return tokenResponse, fmt.Errorf( + "milestone token request failed: url=%s status=%d body=%s", + requestURL, + response.StatusCode, + truncateMilestoneLogBody(body), + ) + } + + if err := json.Unmarshal(body, &tokenResponse); err != nil { + return tokenResponse, err + } + + log.Printf( + "Milestone token received successfully: url=%s tokenType=%s expiresIn=%d hasAccessToken=%t", + requestURL, + tokenResponse.TokenType, + tokenResponse.ExpiresIn, + strings.TrimSpace(tokenResponse.AccessToken) != "", + ) + + return tokenResponse, nil +} + +func normalizeMilestoneHost(value string) string { + value = strings.TrimSpace(value) + value = strings.TrimRight(value, "/") + + if value == "" { + return "" + } + + if strings.HasPrefix(value, "http://") || strings.HasPrefix(value, "https://") { + return value + } + + return "https://" + value +} + +func truncateMilestoneLogBody(body []byte) string { + value := strings.TrimSpace(string(body)) + + if value == "" { + return "" + } + + const maxLength = 1000 + + if len(value) > maxLength { + return value[:maxLength] + "..." + } + + return value +} + +func (s *Server) milestoneEncryptionKey() string { + value := strings.TrimSpace(os.Getenv("MILESTONE_SETTINGS_KEY")) + if value != "" { + return value + } + + return string(s.jwtSecret) +} diff --git a/backend/authz.go b/backend/authz.go new file mode 100644 index 0000000..2171170 --- /dev/null +++ b/backend/authz.go @@ -0,0 +1,32 @@ +// backend/authz.go + +package main + +import "net/http" + +func userHasRight(user User, right string) bool { + for _, userRight := range user.Rights { + if userRight == "admin" || userRight == right { + return true + } + } + + return false +} + +func (s *Server) requireRight(right string, next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + user, ok := userFromContext(r.Context()) + if !ok { + writeError(w, http.StatusUnauthorized, "Unauthorized") + return + } + + if !userHasRight(user, right) { + writeError(w, http.StatusForbidden, "Keine Berechtigung") + return + } + + next(w, r) + } +} diff --git a/backend/cors.go b/backend/cors.go index 6253cd3..243e70f 100644 --- a/backend/cors.go +++ b/backend/cors.go @@ -11,8 +11,8 @@ func (s *Server) cors(next http.Handler) http.Handler { if origin == s.frontendOrigin { w.Header().Set("Access-Control-Allow-Origin", s.frontendOrigin) w.Header().Set("Access-Control-Allow-Credentials", "true") - w.Header().Set("Access-Control-Allow-Headers", "Content-Type") - w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization") + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") } if r.Method == http.MethodOptions { diff --git a/backend/dashboard.go b/backend/dashboard.go new file mode 100644 index 0000000..f7283ab --- /dev/null +++ b/backend/dashboard.go @@ -0,0 +1,436 @@ +// backend/dashboard.go + +package main + +import ( + "encoding/json" + "net/http" + "strconv" + "strings" + "time" +) + +type DashboardWidget struct { + ID string `json:"id"` + UserID string `json:"userId"` + Type string `json:"type"` + Title string `json:"title"` + X int `json:"x"` + Y int `json:"y"` + W int `json:"w"` + H int `json:"h"` + Config json.RawMessage `json:"config"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +type DashboardWidgetRequest struct { + Type string `json:"type"` + Title string `json:"title"` + X int `json:"x"` + Y int `json:"y"` + W int `json:"w"` + H int `json:"h"` + Config json.RawMessage `json:"config"` +} + +type DashboardOperationChange struct { + ID string `json:"id"` + OperationID string `json:"operationId"` + OperationNumber string `json:"operationNumber"` + OperationName string `json:"operationName"` + UserDisplayName string `json:"userDisplayName"` + Action string `json:"action"` + Changes []OperationHistoryChange `json:"changes"` + CreatedAt time.Time `json:"createdAt"` +} + +func normalizeDashboardWidgetInput(input *DashboardWidgetRequest) { + input.Type = strings.TrimSpace(input.Type) + input.Title = strings.TrimSpace(input.Title) + + if input.Type == "" { + input.Type = "note" + } + + if input.Title == "" { + input.Title = "Widget" + } + + if input.X < 0 { + input.X = 0 + } + + if input.Y < 0 { + input.Y = 0 + } + + if input.W < 2 { + input.W = 2 + } + + if input.W > 12 { + input.W = 12 + } + + if input.H < 1 { + input.H = 1 + } + + if input.H > 8 { + input.H = 8 + } + + if len(input.Config) == 0 || !json.Valid(input.Config) { + input.Config = json.RawMessage(`{}`) + } +} + +func scanDashboardWidget(scan func(dest ...any) error) (DashboardWidget, error) { + var widget DashboardWidget + var configRaw []byte + + err := scan( + &widget.ID, + &widget.UserID, + &widget.Type, + &widget.Title, + &widget.X, + &widget.Y, + &widget.W, + &widget.H, + &configRaw, + &widget.CreatedAt, + &widget.UpdatedAt, + ) + + if len(configRaw) == 0 { + configRaw = []byte(`{}`) + } + + widget.Config = json.RawMessage(configRaw) + + return widget, err +} + +func (s *Server) handleListDashboardWidgets(w http.ResponseWriter, r *http.Request) { + user, ok := userFromContext(r.Context()) + if !ok { + writeError(w, http.StatusUnauthorized, "Unauthorized") + return + } + + rows, err := s.db.Query( + r.Context(), + ` + SELECT + id::TEXT, + user_id::TEXT, + type, + title, + x, + y, + w, + h, + config, + created_at, + updated_at + FROM dashboard_widgets + WHERE user_id = $1 + ORDER BY y ASC, x ASC, created_at ASC + `, + user.ID, + ) + + if err != nil { + writeError(w, http.StatusInternalServerError, "Dashboard konnte nicht geladen werden") + return + } + defer rows.Close() + + widgets := []DashboardWidget{} + + for rows.Next() { + widget, err := scanDashboardWidget(rows.Scan) + if err != nil { + writeError(w, http.StatusInternalServerError, "Dashboard konnte nicht gelesen werden") + return + } + + widgets = append(widgets, widget) + } + + if err := rows.Err(); err != nil { + writeError(w, http.StatusInternalServerError, "Dashboard konnte nicht gelesen werden") + return + } + + writeJSON(w, http.StatusOK, map[string]any{ + "widgets": widgets, + }) +} + +func (s *Server) handleCreateDashboardWidget(w http.ResponseWriter, r *http.Request) { + user, ok := userFromContext(r.Context()) + if !ok { + writeError(w, http.StatusUnauthorized, "Unauthorized") + return + } + + var input DashboardWidgetRequest + + if err := readJSON(r, &input); err != nil { + writeError(w, http.StatusBadRequest, "Invalid JSON") + return + } + + normalizeDashboardWidgetInput(&input) + + widget, err := scanDashboardWidget(func(dest ...any) error { + return s.db.QueryRow( + r.Context(), + ` + INSERT INTO dashboard_widgets ( + user_id, + type, + title, + x, + y, + w, + h, + config + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8::JSONB) + RETURNING + id::TEXT, + user_id::TEXT, + type, + title, + x, + y, + w, + h, + config, + created_at, + updated_at + `, + user.ID, + input.Type, + input.Title, + input.X, + input.Y, + input.W, + input.H, + string(input.Config), + ).Scan(dest...) + }) + + if err != nil { + writeError(w, http.StatusInternalServerError, "Widget konnte nicht erstellt werden") + return + } + + writeJSON(w, http.StatusCreated, map[string]any{ + "widget": widget, + }) +} + +func (s *Server) handleUpdateDashboardWidget(w http.ResponseWriter, r *http.Request) { + user, ok := userFromContext(r.Context()) + if !ok { + writeError(w, http.StatusUnauthorized, "Unauthorized") + return + } + + widgetID := strings.TrimSpace(r.PathValue("id")) + if widgetID == "" { + writeError(w, http.StatusBadRequest, "Widget-ID fehlt") + return + } + + var input DashboardWidgetRequest + + if err := readJSON(r, &input); err != nil { + writeError(w, http.StatusBadRequest, "Invalid JSON") + return + } + + normalizeDashboardWidgetInput(&input) + + widget, err := scanDashboardWidget(func(dest ...any) error { + return s.db.QueryRow( + r.Context(), + ` + UPDATE dashboard_widgets + SET + type = $3, + title = $4, + x = $5, + y = $6, + w = $7, + h = $8, + config = $9::JSONB, + updated_at = now() + WHERE id = $1 + AND user_id = $2 + RETURNING + id::TEXT, + user_id::TEXT, + type, + title, + x, + y, + w, + h, + config, + created_at, + updated_at + `, + widgetID, + user.ID, + input.Type, + input.Title, + input.X, + input.Y, + input.W, + input.H, + string(input.Config), + ).Scan(dest...) + }) + + if err != nil { + writeError(w, http.StatusNotFound, "Widget wurde nicht gefunden") + return + } + + writeJSON(w, http.StatusOK, map[string]any{ + "widget": widget, + }) +} + +func (s *Server) handleDeleteDashboardWidget(w http.ResponseWriter, r *http.Request) { + user, ok := userFromContext(r.Context()) + if !ok { + writeError(w, http.StatusUnauthorized, "Unauthorized") + return + } + + widgetID := strings.TrimSpace(r.PathValue("id")) + if widgetID == "" { + writeError(w, http.StatusBadRequest, "Widget-ID fehlt") + return + } + + commandTag, err := s.db.Exec( + r.Context(), + ` + DELETE FROM dashboard_widgets + WHERE id = $1 + AND user_id = $2 + `, + widgetID, + user.ID, + ) + + if err != nil { + writeError(w, http.StatusInternalServerError, "Widget konnte nicht gelöscht werden") + return + } + + if commandTag.RowsAffected() == 0 { + writeError(w, http.StatusNotFound, "Widget wurde nicht gefunden") + return + } + + writeJSON(w, http.StatusOK, map[string]any{ + "ok": true, + }) +} + +func (s *Server) handleDashboardOperationChanges(w http.ResponseWriter, r *http.Request) { + limit := 8 + + if rawLimit := strings.TrimSpace(r.URL.Query().Get("limit")); rawLimit != "" { + parsedLimit, err := strconv.Atoi(rawLimit) + if err == nil { + limit = parsedLimit + } + } + + if limit < 1 { + limit = 1 + } + + if limit > 50 { + limit = 50 + } + + rows, err := s.db.Query( + r.Context(), + ` + SELECT + h.id::TEXT, + h.operation_id::TEXT, + o.operation_number, + o.operation_name, + COALESCE(NULLIF(u.display_name, ''), NULLIF(u.username, ''), u.email, 'Unbekannt'), + h.action, + h.changes, + h.created_at + FROM operation_history h + INNER JOIN operations o ON o.id = h.operation_id + LEFT JOIN users u ON u.id = h.user_id + WHERE h.action <> 'journal' + ORDER BY h.created_at DESC + LIMIT $1 + `, + limit, + ) + + if err != nil { + writeError(w, http.StatusInternalServerError, "Einsatzänderungen konnten nicht geladen werden") + return + } + defer rows.Close() + + changes := []DashboardOperationChange{} + + for rows.Next() { + var item DashboardOperationChange + var changesRaw []byte + + if err := rows.Scan( + &item.ID, + &item.OperationID, + &item.OperationNumber, + &item.OperationName, + &item.UserDisplayName, + &item.Action, + &changesRaw, + &item.CreatedAt, + ); err != nil { + writeError(w, http.StatusInternalServerError, "Einsatzänderungen konnten nicht gelesen werden") + return + } + + item.Changes = []OperationHistoryChange{} + + if len(changesRaw) > 0 { + if err := json.Unmarshal(changesRaw, &item.Changes); err != nil { + writeError(w, http.StatusInternalServerError, "Einsatzänderungen konnten nicht gelesen werden") + return + } + } + + changes = append(changes, item) + } + + if err := rows.Err(); err != nil { + writeError(w, http.StatusInternalServerError, "Einsatzänderungen konnten nicht gelesen werden") + return + } + + writeJSON(w, http.StatusOK, map[string]any{ + "changes": changes, + }) +} diff --git a/backend/device_lookups.go b/backend/device_lookups.go new file mode 100644 index 0000000..41663d8 --- /dev/null +++ b/backend/device_lookups.go @@ -0,0 +1,187 @@ +// backend\device_lookups.go + +package main + +import ( + "context" + "fmt" + "net/http" + "strings" + + "github.com/jackc/pgx/v5" +) + +type DeviceLookupOption struct { + ID string `json:"id"` + Name string `json:"name"` +} + +type CreateDeviceLookupRequest struct { + Name string `json:"name"` +} + +func (s *Server) handleDeviceLookups(w http.ResponseWriter, r *http.Request) { + manufacturers, err := listDeviceLookupOptions(r.Context(), s.db, "device_manufacturers") + if err != nil { + writeError(w, http.StatusInternalServerError, "Hersteller konnten nicht geladen werden") + return + } + + locations, err := listDeviceLookupOptions(r.Context(), s.db, "device_locations") + if err != nil { + writeError(w, http.StatusInternalServerError, "Standorte konnten nicht geladen werden") + return + } + + writeJSON(w, http.StatusOK, map[string]any{ + "manufacturers": manufacturers, + "locations": locations, + }) +} + +func (s *Server) handleCreateDeviceManufacturer(w http.ResponseWriter, r *http.Request) { + s.handleCreateDeviceLookup(w, r, "device_manufacturers", "Hersteller") +} + +func (s *Server) handleCreateDeviceLocation(w http.ResponseWriter, r *http.Request) { + s.handleCreateDeviceLookup(w, r, "device_locations", "Standort") +} + +func (s *Server) handleCreateDeviceLookup( + w http.ResponseWriter, + r *http.Request, + tableName string, + label string, +) { + var input CreateDeviceLookupRequest + + if err := readJSON(r, &input); err != nil { + writeError(w, http.StatusBadRequest, "Invalid JSON") + return + } + + name := strings.TrimSpace(input.Name) + if name == "" { + writeError(w, http.StatusBadRequest, label+" ist erforderlich") + return + } + + option, err := upsertDeviceLookupOption(r.Context(), s.db, tableName, name) + if err != nil { + writeError(w, http.StatusInternalServerError, label+" konnte nicht gespeichert werden") + return + } + + writeJSON(w, http.StatusCreated, map[string]any{ + "option": option, + }) +} + +func listDeviceLookupOptions( + ctx context.Context, + db interface { + Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error) + }, + tableName string, +) ([]DeviceLookupOption, error) { + query := fmt.Sprintf( + ` + SELECT id::TEXT, name + FROM %s + ORDER BY lower(name) ASC + `, + tableName, + ) + + rows, err := db.Query(ctx, query) + if err != nil { + return nil, err + } + defer rows.Close() + + options := []DeviceLookupOption{} + + for rows.Next() { + var option DeviceLookupOption + + if err := rows.Scan(&option.ID, &option.Name); err != nil { + return nil, err + } + + options = append(options, option) + } + + if err := rows.Err(); err != nil { + return nil, err + } + + return options, nil +} + +func ensureDeviceLookupValues( + ctx context.Context, + tx pgx.Tx, + manufacturer string, + location string, +) error { + if err := ensureOptionalDeviceLookupValue(ctx, tx, "device_manufacturers", manufacturer); err != nil { + return err + } + + if err := ensureOptionalDeviceLookupValue(ctx, tx, "device_locations", location); err != nil { + return err + } + + return nil +} + +func ensureOptionalDeviceLookupValue( + ctx context.Context, + db deviceTransaction, + tableName string, + name string, +) error { + name = strings.TrimSpace(name) + if name == "" { + return nil + } + + _, err := upsertDeviceLookupOption(ctx, db, tableName, name) + return err +} + +func upsertDeviceLookupOption( + ctx context.Context, + db deviceTransaction, + tableName string, + name string, +) (DeviceLookupOption, error) { + name = strings.TrimSpace(name) + normalizedName := strings.ToLower(name) + + query := fmt.Sprintf( + ` + INSERT INTO %s ( + name, + normalized_name + ) + VALUES ($1, $2) + ON CONFLICT (normalized_name) + DO UPDATE SET + updated_at = now() + RETURNING id::TEXT, name + `, + tableName, + ) + + var option DeviceLookupOption + + err := db.QueryRow( + ctx, + query, + name, + normalizedName, + ).Scan(&option.ID, &option.Name) + + return option, err +} diff --git a/backend/devices.go b/backend/devices.go index 7c2ddcc..af18c48 100644 --- a/backend/devices.go +++ b/backend/devices.go @@ -22,6 +22,7 @@ type Device struct { MacAddress string `json:"macAddress"` Location string `json:"location"` LoanStatus string `json:"loanStatus"` + IsBaoDevice bool `json:"isBaoDevice"` Comment string `json:"comment"` RelatedDevices []DeviceShort `json:"relatedDevices"` CreatedAt time.Time `json:"createdAt"` @@ -43,6 +44,7 @@ type CreateDeviceRequest struct { MacAddress string `json:"macAddress"` Location string `json:"location"` LoanStatus string `json:"loanStatus"` + IsBaoDevice bool `json:"isBaoDevice"` Comment string `json:"comment"` RelatedDeviceIDs []string `json:"relatedDeviceIds"` } @@ -75,6 +77,7 @@ func (s *Server) handleListDevices(w http.ResponseWriter, r *http.Request) { mac_address, location, loan_status, + is_bao_device, comment, created_at, updated_at @@ -104,6 +107,7 @@ func (s *Server) handleListDevices(w http.ResponseWriter, r *http.Request) { &device.MacAddress, &device.Location, &device.LoanStatus, + &device.IsBaoDevice, &device.Comment, &device.CreatedAt, &device.UpdatedAt, @@ -204,6 +208,11 @@ func (s *Server) handleCreateDevice(w http.ResponseWriter, r *http.Request) { } defer tx.Rollback(r.Context()) + if err := ensureDeviceLookupValues(r.Context(), tx, input.Manufacturer, input.Location); err != nil { + writeError(w, http.StatusInternalServerError, "Hersteller oder Standort konnte nicht gespeichert werden") + return + } + var device Device err = tx.QueryRow( @@ -217,9 +226,10 @@ func (s *Server) handleCreateDevice(w http.ResponseWriter, r *http.Request) { mac_address, location, loan_status, + is_bao_device, comment ) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING id, inventory_number, @@ -229,6 +239,7 @@ func (s *Server) handleCreateDevice(w http.ResponseWriter, r *http.Request) { mac_address, location, loan_status, + is_bao_device, comment, created_at, updated_at @@ -240,6 +251,7 @@ func (s *Server) handleCreateDevice(w http.ResponseWriter, r *http.Request) { input.MacAddress, input.Location, input.LoanStatus, + input.IsBaoDevice, input.Comment, ).Scan( &device.ID, @@ -250,6 +262,7 @@ func (s *Server) handleCreateDevice(w http.ResponseWriter, r *http.Request) { &device.MacAddress, &device.Location, &device.LoanStatus, + &device.IsBaoDevice, &device.Comment, &device.CreatedAt, &device.UpdatedAt, @@ -335,6 +348,11 @@ func (s *Server) handleUpdateDevice(w http.ResponseWriter, r *http.Request) { } defer tx.Rollback(r.Context()) + if err := ensureDeviceLookupValues(r.Context(), tx, input.Manufacturer, input.Location); err != nil { + writeError(w, http.StatusInternalServerError, "Hersteller oder Standort konnte nicht gespeichert werden") + return + } + oldDevice, oldRelatedDeviceIDs, err := getDeviceForHistory(r.Context(), tx, deviceID) if errors.Is(err, pgx.ErrNoRows) { writeError(w, http.StatusNotFound, "Gerät wurde nicht gefunden") @@ -360,7 +378,8 @@ func (s *Server) handleUpdateDevice(w http.ResponseWriter, r *http.Request) { mac_address = $6, location = $7, loan_status = $8, - comment = $9, + is_bao_device = $9, + comment = $10, updated_at = now() WHERE id = $1 RETURNING @@ -372,6 +391,7 @@ func (s *Server) handleUpdateDevice(w http.ResponseWriter, r *http.Request) { mac_address, location, loan_status, + is_bao_device, comment, created_at, updated_at @@ -384,6 +404,7 @@ func (s *Server) handleUpdateDevice(w http.ResponseWriter, r *http.Request) { input.MacAddress, input.Location, input.LoanStatus, + input.IsBaoDevice, input.Comment, ).Scan( &device.ID, @@ -394,6 +415,7 @@ func (s *Server) handleUpdateDevice(w http.ResponseWriter, r *http.Request) { &device.MacAddress, &device.Location, &device.LoanStatus, + &device.IsBaoDevice, &device.Comment, &device.CreatedAt, &device.UpdatedAt, diff --git a/backend/history.go b/backend/history.go index e6b2654..f60f7e1 100644 --- a/backend/history.go +++ b/backend/history.go @@ -1,3 +1,5 @@ +// backend\history.go + package main import ( @@ -28,6 +30,10 @@ type DeviceHistoryChange struct { NewValue string `json:"newValue"` } +type CreateDeviceJournalEntryRequest struct { + Content string `json:"content"` +} + func (s *Server) handleListDeviceHistory(w http.ResponseWriter, r *http.Request) { deviceID := strings.TrimSpace(r.PathValue("id")) @@ -100,6 +106,88 @@ func (s *Server) handleListDeviceHistory(w http.ResponseWriter, r *http.Request) }) } +func (s *Server) handleCreateDeviceJournalEntry(w http.ResponseWriter, r *http.Request) { + deviceID := strings.TrimSpace(r.PathValue("id")) + + if deviceID == "" { + writeError(w, http.StatusBadRequest, "Geräte-ID fehlt") + return + } + + var input CreateDeviceJournalEntryRequest + + if err := readJSON(r, &input); err != nil { + writeError(w, http.StatusBadRequest, "Invalid JSON") + return + } + + content := strings.TrimSpace(input.Content) + + if content == "" { + writeError(w, http.StatusBadRequest, "Journal-Eintrag darf nicht leer sein") + return + } + + user, ok := userFromContext(r.Context()) + if !ok { + writeError(w, http.StatusUnauthorized, "Unauthorized") + return + } + + tx, err := s.db.Begin(r.Context()) + if err != nil { + writeError(w, http.StatusInternalServerError, "Journal-Eintrag konnte nicht erstellt werden") + return + } + defer tx.Rollback(r.Context()) + + device, _, err := getDeviceForHistory(r.Context(), tx, deviceID) + if errors.Is(err, pgx.ErrNoRows) { + writeError(w, http.StatusNotFound, "Gerät wurde nicht gefunden") + return + } + + if err != nil { + writeError(w, http.StatusInternalServerError, "Gerät konnte nicht geladen werden") + return + } + + changes := []DeviceHistoryChange{ + { + Field: "journal", + Label: "Journal", + OldValue: "", + NewValue: content, + }, + } + + if err := recordDeviceHistory(r.Context(), tx, device.ID, user, "journal", changes); err != nil { + writeError(w, http.StatusInternalServerError, "Journal-Eintrag konnte nicht gespeichert werden") + return + } + + if err := tx.Commit(r.Context()); err != nil { + writeError(w, http.StatusInternalServerError, "Journal-Eintrag konnte nicht gespeichert werden") + return + } + + _ = s.createDeviceJournalEvent( + r.Context(), + user, + device, + map[string]any{ + "inventoryNumber": device.InventoryNumber, + "manufacturer": device.Manufacturer, + "model": device.Model, + "action": "created", + }, + ) + + writeJSON(w, http.StatusCreated, map[string]any{ + "ok": true, + }) +} + func userFromContext(ctx context.Context) (User, bool) { user, ok := ctx.Value(userContextKey).(User) return user, ok @@ -153,6 +241,7 @@ func getDeviceForHistory(ctx context.Context, tx pgx.Tx, deviceID string) (Devic mac_address, location, loan_status, + is_bao_device, comment, created_at, updated_at @@ -170,6 +259,7 @@ func getDeviceForHistory(ctx context.Context, tx pgx.Tx, deviceID string) (Devic &device.MacAddress, &device.Location, &device.LoanStatus, + &device.IsBaoDevice, &device.Comment, &device.CreatedAt, &device.UpdatedAt, @@ -214,6 +304,14 @@ func getDeviceForHistory(ctx context.Context, tx pgx.Tx, deviceID string) (Devic return device, relatedDeviceIDs, nil } +func formatHistoryBool(value bool) string { + if value { + return "Ja" + } + + return "Nein" +} + func buildDeviceCreateHistoryChanges( ctx context.Context, tx pgx.Tx, @@ -228,6 +326,7 @@ func buildDeviceCreateHistoryChanges( changes = appendHistoryChange(changes, "macAddress", "MAC-Adresse", "", input.MacAddress) changes = appendHistoryChange(changes, "location", "Standort", "", input.Location) changes = appendHistoryChange(changes, "loanStatus", "Verleih-Status", "", input.LoanStatus) + changes = appendHistoryChange(changes, "isBaoDevice", "BAO-Gerät", "Nein", formatHistoryBool(input.IsBaoDevice)) changes = appendHistoryChange(changes, "comment", "Kommentar", "", input.Comment) relatedDevices, err := formatRelatedDeviceLabels(ctx, tx, input.RelatedDeviceIDs) @@ -256,6 +355,7 @@ func buildDeviceUpdateHistoryChanges( changes = appendHistoryChange(changes, "macAddress", "MAC-Adresse", oldDevice.MacAddress, input.MacAddress) changes = appendHistoryChange(changes, "location", "Standort", oldDevice.Location, input.Location) changes = appendHistoryChange(changes, "loanStatus", "Verleih-Status", oldDevice.LoanStatus, input.LoanStatus) + changes = appendHistoryChange(changes, "isBaoDevice", "BAO-Gerät", formatHistoryBool(oldDevice.IsBaoDevice), formatHistoryBool(input.IsBaoDevice)) changes = appendHistoryChange(changes, "comment", "Kommentar", oldDevice.Comment, input.Comment) oldRelatedDevices, err := formatRelatedDeviceLabels(ctx, tx, oldRelatedDeviceIDs) diff --git a/backend/notifications.go b/backend/notifications.go new file mode 100644 index 0000000..89f6edd --- /dev/null +++ b/backend/notifications.go @@ -0,0 +1,559 @@ +// backend\notifications.go + +package main + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + "net/http" + "strings" + "sync" + "time" +) + +type Notification struct { + ID string `json:"id"` + UserID string `json:"userId"` + Type string `json:"type"` + Title string `json:"title"` + Message string `json:"message"` + EntityType string `json:"entityType"` + EntityID string `json:"entityId"` + Data json.RawMessage `json:"data"` + Visible bool `json:"visible"` + ReadAt *time.Time `json:"readAt"` + CreatedAt time.Time `json:"createdAt"` +} + +type notificationClient struct { + userID string + ch chan Notification +} + +type notificationBroker struct { + mu sync.RWMutex + clients map[*notificationClient]struct{} +} + +var notificationsBroker = ¬ificationBroker{ + clients: map[*notificationClient]struct{}{}, +} + +func (b *notificationBroker) subscribe(userID string) *notificationClient { + client := ¬ificationClient{ + userID: userID, + ch: make(chan Notification, 16), + } + + b.mu.Lock() + b.clients[client] = struct{}{} + b.mu.Unlock() + + return client +} + +func (b *notificationBroker) unsubscribe(client *notificationClient) { + b.mu.Lock() + delete(b.clients, client) + close(client.ch) + b.mu.Unlock() +} + +func (b *notificationBroker) publish(notification Notification) { + b.mu.RLock() + defer b.mu.RUnlock() + + for client := range b.clients { + if client.userID != notification.UserID { + continue + } + + select { + case client.ch <- notification: + default: + } + } +} + +func (s *Server) handleNotificationEvents(w http.ResponseWriter, r *http.Request) { + user, ok := userFromContext(r.Context()) + if !ok { + writeError(w, http.StatusUnauthorized, "Unauthorized") + return + } + + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + w.Header().Set("X-Accel-Buffering", "no") + + flusher, ok := w.(http.Flusher) + if !ok { + writeError(w, http.StatusInternalServerError, "Streaming wird nicht unterstützt") + return + } + + client := notificationsBroker.subscribe(user.ID) + defer notificationsBroker.unsubscribe(client) + + fmt.Fprintf(w, "event: connected\n") + fmt.Fprintf(w, "data: {}\n\n") + flusher.Flush() + + for { + select { + case <-r.Context().Done(): + return + + case notification := <-client.ch: + payload, err := json.Marshal(notification) + if err != nil { + continue + } + + eventName := "notification" + + if !notification.Visible { + switch notification.EntityType { + case "operation": + eventName = "operation-event" + case "device": + eventName = "device-event" + default: + eventName = "notification-event" + } + } + + fmt.Fprintf(w, "event: %s\n", eventName) + fmt.Fprintf(w, "data: %s\n\n", payload) + flusher.Flush() + } + } +} + +func (s *Server) handleListNotifications(w http.ResponseWriter, r *http.Request) { + user, ok := userFromContext(r.Context()) + if !ok { + writeError(w, http.StatusUnauthorized, "Unauthorized") + return + } + + rows, err := s.db.Query( + r.Context(), + ` + SELECT + id, + user_id::TEXT, + type, + title, + message, + entity_type, + COALESCE(entity_id::TEXT, ''), + data, + read_at, + created_at + FROM notifications + WHERE user_id = $1 + AND visible = true + ORDER BY created_at DESC + LIMIT 50 + `, + user.ID, + ) + + if err != nil { + writeError(w, http.StatusInternalServerError, "Benachrichtigungen konnten nicht geladen werden") + return + } + defer rows.Close() + + notifications := []Notification{} + + for rows.Next() { + var notification Notification + var readAt sql.NullTime + var data []byte + + if err := rows.Scan( + ¬ification.ID, + ¬ification.UserID, + ¬ification.Type, + ¬ification.Title, + ¬ification.Message, + ¬ification.EntityType, + ¬ification.EntityID, + &data, + &readAt, + ¬ification.CreatedAt, + ); err != nil { + writeError(w, http.StatusInternalServerError, "Benachrichtigungen konnten nicht gelesen werden") + return + } + + if readAt.Valid { + notification.ReadAt = &readAt.Time + } + + notification.Data = json.RawMessage(data) + notifications = append(notifications, notification) + } + + writeJSON(w, http.StatusOK, map[string]any{ + "notifications": notifications, + }) +} + +func (s *Server) handleMarkNotificationRead(w http.ResponseWriter, r *http.Request) { + user, ok := userFromContext(r.Context()) + if !ok { + writeError(w, http.StatusUnauthorized, "Unauthorized") + return + } + + notificationID := strings.TrimSpace(r.PathValue("id")) + if notificationID == "" { + writeError(w, http.StatusBadRequest, "Benachrichtigungs-ID fehlt") + return + } + + _, err := s.db.Exec( + r.Context(), + ` + UPDATE notifications + SET read_at = COALESCE(read_at, now()) + WHERE id = $1 AND user_id = $2 + `, + notificationID, + user.ID, + ) + + if err != nil { + writeError(w, http.StatusInternalServerError, "Benachrichtigung konnte nicht aktualisiert werden") + return + } + + writeJSON(w, http.StatusOK, map[string]any{ + "ok": true, + }) +} + +func (s *Server) handleMarkAllNotificationsRead(w http.ResponseWriter, r *http.Request) { + user, ok := userFromContext(r.Context()) + if !ok { + writeError(w, http.StatusUnauthorized, "Unauthorized") + return + } + + _, err := s.db.Exec( + r.Context(), + ` + UPDATE notifications + SET read_at = COALESCE(read_at, now()) + WHERE user_id = $1 AND read_at IS NULL + `, + user.ID, + ) + + if err != nil { + writeError(w, http.StatusInternalServerError, "Benachrichtigungen konnten nicht aktualisiert werden") + return + } + + writeJSON(w, http.StatusOK, map[string]any{ + "ok": true, + }) +} + +func (s *Server) createOperationNotification( + ctx context.Context, + actor User, + operation Operation, + notificationType string, + title string, + message string, + data map[string]any, +) error { + dataJSON, err := json.Marshal(data) + if err != nil { + return err + } + + rows, err := s.db.Query( + ctx, + ` + SELECT id + FROM users + WHERE id <> $1 + `, + actor.ID, + ) + if err != nil { + return err + } + defer rows.Close() + + recipientIDs := []string{} + + for rows.Next() { + var userID string + if err := rows.Scan(&userID); err != nil { + return err + } + + recipientIDs = append(recipientIDs, userID) + } + + for _, recipientID := range recipientIDs { + var notification Notification + var rawData []byte + + err := s.db.QueryRow( + ctx, + ` + INSERT INTO notifications ( + user_id, + type, + title, + message, + entity_type, + entity_id, + data + ) + VALUES ($1, $2, $3, $4, 'operation', $5, $6::JSONB) + RETURNING + id, + user_id::TEXT, + type, + title, + message, + entity_type, + COALESCE(entity_id::TEXT, ''), + data, + created_at + `, + recipientID, + notificationType, + title, + message, + operation.ID, + string(dataJSON), + ).Scan( + ¬ification.ID, + ¬ification.UserID, + ¬ification.Type, + ¬ification.Title, + ¬ification.Message, + ¬ification.EntityType, + ¬ification.EntityID, + &rawData, + ¬ification.CreatedAt, + ) + + if err != nil { + return err + } + + notification.Data = json.RawMessage(rawData) + notificationsBroker.publish(notification) + } + + return nil +} + +func (s *Server) createOperationJournalEvent( + ctx context.Context, + actor User, + operation Operation, + data map[string]any, +) error { + dataJSON, err := json.Marshal(data) + if err != nil { + return err + } + + rows, err := s.db.Query( + ctx, + ` + SELECT id + FROM users + WHERE id <> $1 + `, + actor.ID, + ) + if err != nil { + return err + } + defer rows.Close() + + for rows.Next() { + var recipientID string + if err := rows.Scan(&recipientID); err != nil { + return err + } + + var notification Notification + var rawData []byte + + err := s.db.QueryRow( + ctx, + ` + INSERT INTO notifications ( + user_id, + type, + title, + message, + entity_type, + entity_id, + data, + visible + ) + VALUES ( + $1, + 'operation.journal', + 'Journal aktualisiert', + 'Ein Journal-Eintrag wurde aktualisiert.', + 'operation', + $2, + $3::JSONB, + false + ) + RETURNING + id, + user_id::TEXT, + type, + title, + message, + entity_type, + COALESCE(entity_id::TEXT, ''), + data, + visible, + created_at + `, + recipientID, + operation.ID, + string(dataJSON), + ).Scan( + ¬ification.ID, + ¬ification.UserID, + ¬ification.Type, + ¬ification.Title, + ¬ification.Message, + ¬ification.EntityType, + ¬ification.EntityID, + &rawData, + ¬ification.Visible, + ¬ification.CreatedAt, + ) + + if err != nil { + return err + } + + notification.Data = json.RawMessage(rawData) + notificationsBroker.publish(notification) + } + + return rows.Err() +} + +func (s *Server) createDeviceJournalEvent( + ctx context.Context, + actor User, + device Device, + data map[string]any, +) error { + dataJSON, err := json.Marshal(data) + if err != nil { + return err + } + + rows, err := s.db.Query( + ctx, + ` + SELECT id + FROM users + WHERE id <> $1 + AND ( + 'admin' = ANY(rights) + OR 'devices:read' = ANY(rights) + ) + `, + actor.ID, + ) + if err != nil { + return err + } + defer rows.Close() + + for rows.Next() { + var recipientID string + if err := rows.Scan(&recipientID); err != nil { + return err + } + + var notification Notification + var rawData []byte + + err := s.db.QueryRow( + ctx, + ` + INSERT INTO notifications ( + user_id, + type, + title, + message, + entity_type, + entity_id, + data, + visible + ) + VALUES ( + $1, + 'device.journal', + 'Geräte-Journal aktualisiert', + 'Ein Geräte-Journal-Eintrag wurde hinzugefügt.', + 'device', + $2, + $3::JSONB, + false + ) + RETURNING + id, + user_id::TEXT, + type, + title, + message, + entity_type, + COALESCE(entity_id::TEXT, ''), + data, + visible, + created_at + `, + recipientID, + device.ID, + string(dataJSON), + ).Scan( + ¬ification.ID, + ¬ification.UserID, + ¬ification.Type, + ¬ification.Title, + ¬ification.Message, + ¬ification.EntityType, + ¬ification.EntityID, + &rawData, + ¬ification.Visible, + ¬ification.CreatedAt, + ) + + if err != nil { + return err + } + + notification.Data = json.RawMessage(rawData) + notificationsBroker.publish(notification) + } + + return rows.Err() +} diff --git a/backend/operations.go b/backend/operations.go new file mode 100644 index 0000000..da0fa62 --- /dev/null +++ b/backend/operations.go @@ -0,0 +1,1125 @@ +// backend\operations.go + +package main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "strings" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" +) + +type Operation struct { + ID string `json:"id"` + OperationNumber string `json:"operationNumber"` + OperationName string `json:"operationName"` + Offense string `json:"offense"` + CaseWorker string `json:"caseWorker"` + TargetObject string `json:"targetObject"` + KwAddress string `json:"kwAddress"` + OperationLeader string `json:"operationLeader"` + OperationTeam string `json:"operationTeam"` + Legend string `json:"legend"` + Camera string `json:"camera"` + Router string `json:"router"` + Technology string `json:"technology"` + Switchbox string `json:"switchbox"` + HardDrive string `json:"hardDrive"` + Laptop string `json:"laptop"` + Remark string `json:"remark"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +type CreateOperationJournalEntryRequest struct { + Content string `json:"content"` +} + +type UpdateOperationJournalEntryRequest struct { + Content string `json:"content"` +} + +type CreateOperationRequest struct { + OperationNumber string `json:"operationNumber"` + OperationName string `json:"operationName"` + Offense string `json:"offense"` + CaseWorker string `json:"caseWorker"` + TargetObject string `json:"targetObject"` + KwAddress string `json:"kwAddress"` + OperationLeader string `json:"operationLeader"` + OperationTeam string `json:"operationTeam"` + Legend string `json:"legend"` + Camera string `json:"camera"` + Router string `json:"router"` + Technology string `json:"technology"` + Switchbox string `json:"switchbox"` + HardDrive string `json:"hardDrive"` + Laptop string `json:"laptop"` + Remark string `json:"remark"` +} + +type UpdateOperationRequest = CreateOperationRequest + +type OperationHistoryEntry struct { + ID string `json:"id"` + OperationID string `json:"operationId"` + UserID string `json:"userId"` + UserDisplayName string `json:"userDisplayName"` + Action string `json:"action"` + Changes []OperationHistoryChange `json:"changes"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + CanEdit bool `json:"canEdit"` +} + +type OperationHistoryChange struct { + Field string `json:"field"` + Label string `json:"label"` + OldValue string `json:"oldValue"` + NewValue string `json:"newValue"` +} + +func (s *Server) handleOperations(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + s.handleListOperations(w, r) + case http.MethodPost: + s.handleCreateOperation(w, r) + default: + writeError(w, http.StatusMethodNotAllowed, "Method not allowed") + } +} + +func (s *Server) handleOperation(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodPut: + s.handleUpdateOperation(w, r) + default: + writeError(w, http.StatusMethodNotAllowed, "Method not allowed") + } +} + +func (s *Server) handleListOperations(w http.ResponseWriter, r *http.Request) { + rows, err := s.db.Query( + r.Context(), + ` + SELECT + id, + operation_number, + operation_name, + offense, + case_worker, + target_object, + kw_address, + operation_leader, + operation_team, + legend, + camera, + router, + technology, + switchbox, + hard_drive, + laptop, + remark, + created_at, + updated_at + FROM operations + ORDER BY operation_number ASC + `, + ) + + if err != nil { + writeError(w, http.StatusInternalServerError, "Could not load operations") + return + } + defer rows.Close() + + operations := []Operation{} + + for rows.Next() { + var operation Operation + + if err := rows.Scan( + &operation.ID, + &operation.OperationNumber, + &operation.OperationName, + &operation.Offense, + &operation.CaseWorker, + &operation.TargetObject, + &operation.KwAddress, + &operation.OperationLeader, + &operation.OperationTeam, + &operation.Legend, + &operation.Camera, + &operation.Router, + &operation.Technology, + &operation.Switchbox, + &operation.HardDrive, + &operation.Laptop, + &operation.Remark, + &operation.CreatedAt, + &operation.UpdatedAt, + ); err != nil { + writeError(w, http.StatusInternalServerError, "Could not read operations") + return + } + + operations = append(operations, operation) + } + + if err := rows.Err(); err != nil { + writeError(w, http.StatusInternalServerError, "Could not read operations") + return + } + + writeJSON(w, http.StatusOK, map[string]any{ + "operations": operations, + }) +} + +func (s *Server) handleCreateOperation(w http.ResponseWriter, r *http.Request) { + var input CreateOperationRequest + + if err := readJSON(r, &input); err != nil { + writeError(w, http.StatusBadRequest, "Invalid JSON") + return + } + + normalizeOperationInput(&input) + + if input.OperationNumber == "" { + writeError(w, http.StatusBadRequest, "Einsatznummer ist erforderlich") + return + } + + user, ok := userFromContext(r.Context()) + if !ok { + writeError(w, http.StatusUnauthorized, "Unauthorized") + return + } + + tx, err := s.db.Begin(r.Context()) + if err != nil { + writeError(w, http.StatusInternalServerError, "Could not create operation") + return + } + defer tx.Rollback(r.Context()) + + var operation Operation + + err = tx.QueryRow( + r.Context(), + ` + INSERT INTO operations ( + operation_number, + operation_name, + offense, + case_worker, + target_object, + kw_address, + operation_leader, + operation_team, + legend, + camera, + router, + technology, + switchbox, + hard_drive, + laptop, + remark + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16) + RETURNING + id, + operation_number, + operation_name, + offense, + case_worker, + target_object, + kw_address, + operation_leader, + operation_team, + legend, + camera, + router, + technology, + switchbox, + hard_drive, + laptop, + remark, + created_at, + updated_at + `, + input.OperationNumber, + input.OperationName, + input.Offense, + input.CaseWorker, + input.TargetObject, + input.KwAddress, + input.OperationLeader, + input.OperationTeam, + input.Legend, + input.Camera, + input.Router, + input.Technology, + input.Switchbox, + input.HardDrive, + input.Laptop, + input.Remark, + ).Scan( + &operation.ID, + &operation.OperationNumber, + &operation.OperationName, + &operation.Offense, + &operation.CaseWorker, + &operation.TargetObject, + &operation.KwAddress, + &operation.OperationLeader, + &operation.OperationTeam, + &operation.Legend, + &operation.Camera, + &operation.Router, + &operation.Technology, + &operation.Switchbox, + &operation.HardDrive, + &operation.Laptop, + &operation.Remark, + &operation.CreatedAt, + &operation.UpdatedAt, + ) + + if err != nil { + var pgErr *pgconn.PgError + + if errors.As(err, &pgErr) && pgErr.Code == "23505" { + writeError(w, http.StatusConflict, "Einsatz mit dieser Einsatznummer existiert bereits") + return + } + + writeError(w, http.StatusInternalServerError, "Could not create operation") + return + } + + changes := buildOperationCreateHistoryChanges(input) + + if err := recordOperationHistory(r.Context(), tx, operation.ID, user, "created", changes); err != nil { + writeError(w, http.StatusInternalServerError, "Could not create operation history") + return + } + + if err := tx.Commit(r.Context()); err != nil { + writeError(w, http.StatusInternalServerError, "Could not create operation") + return + } + + if err := s.createOperationNotification( + r.Context(), + user, + operation, + "operation.created", + "Neuer Einsatz", + fmt.Sprintf("Einsatz %s wurde erstellt.", operation.OperationNumber), + map[string]any{ + "operationNumber": operation.OperationNumber, + "operationName": operation.OperationName, + }, + ); err != nil { + // nicht abbrechen, Einsatz wurde bereits erstellt + } + + writeJSON(w, http.StatusCreated, map[string]any{ + "operation": operation, + }) +} + +func (s *Server) handleUpdateOperation(w http.ResponseWriter, r *http.Request) { + operationID := strings.TrimSpace(r.PathValue("id")) + + if operationID == "" { + writeError(w, http.StatusBadRequest, "Einsatz-ID fehlt") + return + } + + var input UpdateOperationRequest + + if err := readJSON(r, &input); err != nil { + writeError(w, http.StatusBadRequest, "Invalid JSON") + return + } + + normalizeOperationInput(&input) + + if input.OperationNumber == "" { + writeError(w, http.StatusBadRequest, "Einsatznummer ist erforderlich") + return + } + + user, ok := userFromContext(r.Context()) + if !ok { + writeError(w, http.StatusUnauthorized, "Unauthorized") + return + } + + tx, err := s.db.Begin(r.Context()) + if err != nil { + writeError(w, http.StatusInternalServerError, "Could not update operation") + return + } + defer tx.Rollback(r.Context()) + + oldOperation, err := getOperationForHistory(r.Context(), tx, operationID) + if errors.Is(err, pgx.ErrNoRows) { + writeError(w, http.StatusNotFound, "Einsatz wurde nicht gefunden") + return + } + + if err != nil { + writeError(w, http.StatusInternalServerError, "Could not load operation history state") + return + } + + var operation Operation + + err = tx.QueryRow( + r.Context(), + ` + UPDATE operations + SET + operation_number = $2, + operation_name = $3, + offense = $4, + case_worker = $5, + target_object = $6, + kw_address = $7, + operation_leader = $8, + operation_team = $9, + legend = $10, + camera = $11, + router = $12, + technology = $13, + switchbox = $14, + hard_drive = $15, + laptop = $16, + remark = $17, + updated_at = now() + WHERE id = $1 + RETURNING + id, + operation_number, + operation_name, + offense, + case_worker, + target_object, + kw_address, + operation_leader, + operation_team, + legend, + camera, + router, + technology, + switchbox, + hard_drive, + laptop, + remark, + created_at, + updated_at + `, + operationID, + input.OperationNumber, + input.OperationName, + input.Offense, + input.CaseWorker, + input.TargetObject, + input.KwAddress, + input.OperationLeader, + input.OperationTeam, + input.Legend, + input.Camera, + input.Router, + input.Technology, + input.Switchbox, + input.HardDrive, + input.Laptop, + input.Remark, + ).Scan( + &operation.ID, + &operation.OperationNumber, + &operation.OperationName, + &operation.Offense, + &operation.CaseWorker, + &operation.TargetObject, + &operation.KwAddress, + &operation.OperationLeader, + &operation.OperationTeam, + &operation.Legend, + &operation.Camera, + &operation.Router, + &operation.Technology, + &operation.Switchbox, + &operation.HardDrive, + &operation.Laptop, + &operation.Remark, + &operation.CreatedAt, + &operation.UpdatedAt, + ) + + if errors.Is(err, pgx.ErrNoRows) { + writeError(w, http.StatusNotFound, "Einsatz wurde nicht gefunden") + return + } + + if err != nil { + var pgErr *pgconn.PgError + + if errors.As(err, &pgErr) && pgErr.Code == "23505" { + writeError(w, http.StatusConflict, "Einsatz mit dieser Einsatznummer existiert bereits") + return + } + + writeError(w, http.StatusInternalServerError, "Could not update operation") + return + } + + changes := buildOperationUpdateHistoryChanges(oldOperation, input) + + if len(changes) > 0 { + if err := recordOperationHistory(r.Context(), tx, operation.ID, user, "updated", changes); err != nil { + writeError(w, http.StatusInternalServerError, "Could not create operation history") + return + } + } + + if err := tx.Commit(r.Context()); err != nil { + writeError(w, http.StatusInternalServerError, "Could not update operation") + return + } + + if err := s.createOperationNotification( + r.Context(), + user, + operation, + "operation.updated", + "Einsatz geändert", + fmt.Sprintf("Einsatz %s wurde geändert.", operation.OperationNumber), + map[string]any{ + "operationNumber": operation.OperationNumber, + "operationName": operation.OperationName, + "changes": changes, + }, + ); err != nil { + // nicht abbrechen, Änderung wurde bereits gespeichert + } + + writeJSON(w, http.StatusOK, map[string]any{ + "operation": operation, + }) +} + +func (s *Server) handleListOperationHistory(w http.ResponseWriter, r *http.Request) { + operationID := strings.TrimSpace(r.PathValue("id")) + + if operationID == "" { + writeError(w, http.StatusBadRequest, "Einsatz-ID fehlt") + return + } + + user, ok := userFromContext(r.Context()) + if !ok { + writeError(w, http.StatusUnauthorized, "Unauthorized") + return + } + + rows, err := s.db.Query( + r.Context(), + ` + SELECT + h.id, + h.operation_id, + COALESCE(h.user_id::TEXT, ''), + COALESCE(NULLIF(u.display_name, ''), NULLIF(u.username, ''), u.email, 'Unbekannt'), + h.action, + h.changes, + h.created_at, + h.updated_at + FROM operation_history h + LEFT JOIN users u ON u.id = h.user_id + WHERE h.operation_id = $1 + ORDER BY h.created_at DESC + `, + operationID, + ) + + if err != nil { + writeError(w, http.StatusInternalServerError, "Could not load operation history") + return + } + defer rows.Close() + + history := []OperationHistoryEntry{} + + for rows.Next() { + var entry OperationHistoryEntry + var changesRaw []byte + + if err := rows.Scan( + &entry.ID, + &entry.OperationID, + &entry.UserID, + &entry.UserDisplayName, + &entry.Action, + &changesRaw, + &entry.CreatedAt, + &entry.UpdatedAt, + ); err != nil { + writeError(w, http.StatusInternalServerError, "Could not read operation history") + return + } + + if len(changesRaw) > 0 { + if err := json.Unmarshal(changesRaw, &entry.Changes); err != nil { + writeError(w, http.StatusInternalServerError, "Could not parse operation history") + return + } + } + + entry.CanEdit = + entry.Action == "journal" && + entry.UserID == user.ID && + time.Since(entry.CreatedAt) <= 10*time.Minute + + history = append(history, entry) + } + + if err := rows.Err(); err != nil { + writeError(w, http.StatusInternalServerError, "Could not read operation history") + return + } + + writeJSON(w, http.StatusOK, map[string]any{ + "history": history, + }) +} + +func getOperationForHistory(ctx context.Context, tx pgx.Tx, operationID string) (Operation, error) { + var operation Operation + + err := tx.QueryRow( + ctx, + ` + SELECT + id, + operation_number, + operation_name, + offense, + case_worker, + target_object, + kw_address, + operation_leader, + operation_team, + legend, + camera, + router, + technology, + switchbox, + hard_drive, + laptop, + remark, + created_at, + updated_at + FROM operations + WHERE id = $1 + LIMIT 1 + `, + operationID, + ).Scan( + &operation.ID, + &operation.OperationNumber, + &operation.OperationName, + &operation.Offense, + &operation.CaseWorker, + &operation.TargetObject, + &operation.KwAddress, + &operation.OperationLeader, + &operation.OperationTeam, + &operation.Legend, + &operation.Camera, + &operation.Router, + &operation.Technology, + &operation.Switchbox, + &operation.HardDrive, + &operation.Laptop, + &operation.Remark, + &operation.CreatedAt, + &operation.UpdatedAt, + ) + + return operation, err +} + +func recordOperationHistory( + ctx context.Context, + tx pgx.Tx, + operationID string, + user User, + action string, + changes []OperationHistoryChange, +) error { + changesJSON, err := json.Marshal(changes) + if err != nil { + return err + } + + _, err = tx.Exec( + ctx, + ` + INSERT INTO operation_history ( + operation_id, + user_id, + action, + changes + ) + VALUES ($1, $2, $3, $4::JSONB) + `, + operationID, + user.ID, + action, + string(changesJSON), + ) + + return err +} + +func buildOperationCreateHistoryChanges(input CreateOperationRequest) []OperationHistoryChange { + changes := []OperationHistoryChange{} + + changes = appendOperationHistoryChange(changes, "operationNumber", "Einsatznummer", "", input.OperationNumber) + changes = appendOperationHistoryChange(changes, "operationName", "Einsatzname", "", input.OperationName) + changes = appendOperationHistoryChange(changes, "offense", "Delikt", "", input.Offense) + changes = appendOperationHistoryChange(changes, "caseWorker", "Sachbearbeitung", "", input.CaseWorker) + changes = appendOperationHistoryChange(changes, "targetObject", "Zielobjekt", "", input.TargetObject) + changes = appendOperationHistoryChange(changes, "kwAddress", "KW", "", input.KwAddress) + changes = appendOperationHistoryChange(changes, "operationLeader", "Einsatzleiter", "", input.OperationLeader) + changes = appendOperationHistoryChange(changes, "operationTeam", "Einsatzteam", "", input.OperationTeam) + changes = appendOperationHistoryChange(changes, "legend", "Legende", "", input.Legend) + changes = appendOperationHistoryChange(changes, "camera", "Kamera", "", input.Camera) + changes = appendOperationHistoryChange(changes, "router", "Router", "", input.Router) + changes = appendOperationHistoryChange(changes, "technology", "Technik", "", input.Technology) + changes = appendOperationHistoryChange(changes, "switchbox", "Switchbox", "", input.Switchbox) + changes = appendOperationHistoryChange(changes, "hardDrive", "Festplatte", "", input.HardDrive) + changes = appendOperationHistoryChange(changes, "laptop", "Laptop", "", input.Laptop) + changes = appendOperationHistoryChange(changes, "remark", "Bemerkung", "", input.Remark) + + return changes +} + +func buildOperationUpdateHistoryChanges(oldOperation Operation, input UpdateOperationRequest) []OperationHistoryChange { + changes := []OperationHistoryChange{} + + changes = appendOperationHistoryChange(changes, "operationNumber", "Einsatznummer", oldOperation.OperationNumber, input.OperationNumber) + changes = appendOperationHistoryChange(changes, "operationName", "Einsatzname", oldOperation.OperationName, input.OperationName) + changes = appendOperationHistoryChange(changes, "offense", "Delikt", oldOperation.Offense, input.Offense) + changes = appendOperationHistoryChange(changes, "caseWorker", "Sachbearbeitung", oldOperation.CaseWorker, input.CaseWorker) + changes = appendOperationHistoryChange(changes, "targetObject", "Zielobjekt", oldOperation.TargetObject, input.TargetObject) + changes = appendOperationHistoryChange(changes, "kwAddress", "KW", oldOperation.KwAddress, input.KwAddress) + changes = appendOperationHistoryChange(changes, "operationLeader", "Einsatzleiter", oldOperation.OperationLeader, input.OperationLeader) + changes = appendOperationHistoryChange(changes, "operationTeam", "Einsatzteam", oldOperation.OperationTeam, input.OperationTeam) + changes = appendOperationHistoryChange(changes, "legend", "Legende", oldOperation.Legend, input.Legend) + changes = appendOperationHistoryChange(changes, "camera", "Kamera", oldOperation.Camera, input.Camera) + changes = appendOperationHistoryChange(changes, "router", "Router", oldOperation.Router, input.Router) + changes = appendOperationHistoryChange(changes, "technology", "Technik", oldOperation.Technology, input.Technology) + changes = appendOperationHistoryChange(changes, "switchbox", "Switchbox", oldOperation.Switchbox, input.Switchbox) + changes = appendOperationHistoryChange(changes, "hardDrive", "Festplatte", oldOperation.HardDrive, input.HardDrive) + changes = appendOperationHistoryChange(changes, "laptop", "Laptop", oldOperation.Laptop, input.Laptop) + changes = appendOperationHistoryChange(changes, "remark", "Bemerkung", oldOperation.Remark, input.Remark) + + return changes +} + +func appendOperationHistoryChange( + changes []OperationHistoryChange, + field string, + label string, + oldValue string, + newValue string, +) []OperationHistoryChange { + oldValue = strings.TrimSpace(oldValue) + newValue = strings.TrimSpace(newValue) + + if oldValue == newValue { + return changes + } + + return append(changes, OperationHistoryChange{ + Field: field, + Label: label, + OldValue: displayHistoryValue(oldValue), + NewValue: displayHistoryValue(newValue), + }) +} + +func normalizeOperationInput(input *CreateOperationRequest) { + input.OperationNumber = strings.TrimSpace(input.OperationNumber) + input.OperationName = strings.TrimSpace(input.OperationName) + input.Offense = strings.TrimSpace(input.Offense) + input.CaseWorker = strings.TrimSpace(input.CaseWorker) + input.TargetObject = strings.TrimSpace(input.TargetObject) + input.KwAddress = strings.TrimSpace(input.KwAddress) + input.OperationLeader = strings.TrimSpace(input.OperationLeader) + input.OperationTeam = strings.TrimSpace(input.OperationTeam) + input.Legend = strings.TrimSpace(input.Legend) + input.Camera = strings.TrimSpace(input.Camera) + input.Router = strings.TrimSpace(input.Router) + input.Technology = strings.TrimSpace(input.Technology) + input.Switchbox = strings.TrimSpace(input.Switchbox) + input.HardDrive = strings.TrimSpace(input.HardDrive) + input.Laptop = strings.TrimSpace(input.Laptop) + input.Remark = strings.TrimSpace(input.Remark) +} + +func (s *Server) handleCreateOperationJournalEntry(w http.ResponseWriter, r *http.Request) { + operationID := strings.TrimSpace(r.PathValue("id")) + + if operationID == "" { + writeError(w, http.StatusBadRequest, "Einsatz-ID fehlt") + return + } + + var input CreateOperationJournalEntryRequest + + if err := readJSON(r, &input); err != nil { + writeError(w, http.StatusBadRequest, "Invalid JSON") + return + } + + content := strings.TrimSpace(input.Content) + + if content == "" { + writeError(w, http.StatusBadRequest, "Journal-Eintrag darf nicht leer sein") + return + } + + user, ok := userFromContext(r.Context()) + if !ok { + writeError(w, http.StatusUnauthorized, "Unauthorized") + return + } + + tx, err := s.db.Begin(r.Context()) + if err != nil { + writeError(w, http.StatusInternalServerError, "Journal-Eintrag konnte nicht erstellt werden") + return + } + defer tx.Rollback(r.Context()) + + operation, err := getOperationForHistory(r.Context(), tx, operationID) + if errors.Is(err, pgx.ErrNoRows) { + writeError(w, http.StatusNotFound, "Einsatz wurde nicht gefunden") + return + } + + if err != nil { + writeError(w, http.StatusInternalServerError, "Einsatz konnte nicht geladen werden") + return + } + + changes := []OperationHistoryChange{ + { + Field: "journal", + Label: "Journal", + OldValue: "", + NewValue: content, + }, + } + + if err := recordOperationHistory(r.Context(), tx, operation.ID, user, "journal", changes); err != nil { + writeError(w, http.StatusInternalServerError, "Journal-Eintrag konnte nicht gespeichert werden") + return + } + + if err := tx.Commit(r.Context()); err != nil { + writeError(w, http.StatusInternalServerError, "Journal-Eintrag konnte nicht gespeichert werden") + return + } + + _ = s.createOperationJournalEvent( + r.Context(), + user, + operation, + map[string]any{ + "operationNumber": operation.OperationNumber, + "operationName": operation.OperationName, + }, + ) + + writeJSON(w, http.StatusCreated, map[string]any{ + "ok": true, + }) +} + +func (s *Server) handleUpdateOperationJournalEntry(w http.ResponseWriter, r *http.Request) { + operationID := strings.TrimSpace(r.PathValue("id")) + historyID := strings.TrimSpace(r.PathValue("historyId")) + + if operationID == "" { + writeError(w, http.StatusBadRequest, "Einsatz-ID fehlt") + return + } + + if historyID == "" { + writeError(w, http.StatusBadRequest, "Journal-ID fehlt") + return + } + + var input UpdateOperationJournalEntryRequest + + if err := readJSON(r, &input); err != nil { + writeError(w, http.StatusBadRequest, "Invalid JSON") + return + } + + content := strings.TrimSpace(input.Content) + + if content == "" { + writeError(w, http.StatusBadRequest, "Journal-Eintrag darf nicht leer sein") + return + } + + user, ok := userFromContext(r.Context()) + if !ok { + writeError(w, http.StatusUnauthorized, "Unauthorized") + return + } + + tx, err := s.db.Begin(r.Context()) + if err != nil { + writeError(w, http.StatusInternalServerError, "Journal-Eintrag konnte nicht gespeichert werden") + return + } + defer tx.Rollback(r.Context()) + + operation, err := getOperationForHistory(r.Context(), tx, operationID) + if errors.Is(err, pgx.ErrNoRows) { + writeError(w, http.StatusNotFound, "Einsatz wurde nicht gefunden") + return + } + + if err != nil { + writeError(w, http.StatusInternalServerError, "Einsatz konnte nicht geladen werden") + return + } + + var changesRaw []byte + var createdAt time.Time + + err = tx.QueryRow( + r.Context(), + ` + SELECT changes, created_at + FROM operation_history + WHERE id = $1 + AND operation_id = $2 + AND user_id = $3 + AND action = 'journal' + LIMIT 1 + `, + historyID, + operationID, + user.ID, + ).Scan(&changesRaw, &createdAt) + + if errors.Is(err, pgx.ErrNoRows) { + writeError(w, http.StatusNotFound, "Journal-Eintrag wurde nicht gefunden oder darf nicht bearbeitet werden") + return + } + + if err != nil { + writeError(w, http.StatusInternalServerError, "Journal-Eintrag konnte nicht geladen werden") + return + } + + if time.Since(createdAt) > 10*time.Minute { + writeError(w, http.StatusForbidden, "Journal-Eintrag kann nur innerhalb von 10 Minuten bearbeitet werden") + return + } + + var oldChanges []OperationHistoryChange + + if len(changesRaw) > 0 { + if err := json.Unmarshal(changesRaw, &oldChanges); err != nil { + writeError(w, http.StatusInternalServerError, "Journal-Eintrag konnte nicht gelesen werden") + return + } + } + + oldContent := "" + + for _, change := range oldChanges { + if change.Field == "journal" { + oldContent = change.NewValue + break + } + } + + if strings.TrimSpace(oldContent) == content { + writeJSON(w, http.StatusOK, map[string]any{ + "ok": true, + }) + return + } + + changes := []OperationHistoryChange{ + { + Field: "journal", + Label: "Journal", + OldValue: oldContent, + NewValue: content, + }, + } + + changesJSON, err := json.Marshal(changes) + if err != nil { + writeError(w, http.StatusInternalServerError, "Journal-Eintrag konnte nicht vorbereitet werden") + return + } + + commandTag, err := tx.Exec( + r.Context(), + ` + UPDATE operation_history + SET + changes = $4::JSONB, + updated_at = now() + WHERE id = $1 + AND operation_id = $2 + AND user_id = $3 + AND action = 'journal' + AND created_at >= now() - interval '10 minutes' + `, + historyID, + operationID, + user.ID, + string(changesJSON), + ) + + if err != nil { + writeError(w, http.StatusInternalServerError, "Journal-Eintrag konnte nicht gespeichert werden") + return + } + + if commandTag.RowsAffected() == 0 { + writeError(w, http.StatusForbidden, "Journal-Eintrag kann nicht mehr bearbeitet werden") + return + } + + if err := tx.Commit(r.Context()); err != nil { + writeError(w, http.StatusInternalServerError, "Journal-Eintrag konnte nicht gespeichert werden") + return + } + + _ = s.createOperationJournalEvent( + r.Context(), + user, + operation, + map[string]any{ + "operationNumber": operation.OperationNumber, + "operationName": operation.OperationName, + "historyId": historyID, + "action": "updated", + }, + ) + + writeJSON(w, http.StatusOK, map[string]any{ + "ok": true, + }) +} + +func (s *Server) handleListUnreadOperationJournals(w http.ResponseWriter, r *http.Request) { + user, ok := userFromContext(r.Context()) + if !ok { + writeError(w, http.StatusUnauthorized, "Unauthorized") + return + } + + rows, err := s.db.Query( + r.Context(), + ` + SELECT entity_id::TEXT + FROM notifications + WHERE user_id = $1 + AND visible = false + AND type = 'operation.journal' + AND entity_type = 'operation' + AND entity_id IS NOT NULL + AND read_at IS NULL + GROUP BY entity_id + `, + user.ID, + ) + + if err != nil { + writeError(w, http.StatusInternalServerError, "Ungelesene Journal-Einträge konnten nicht geladen werden") + return + } + defer rows.Close() + + operationIds := []string{} + + for rows.Next() { + var operationID string + if err := rows.Scan(&operationID); err != nil { + writeError(w, http.StatusInternalServerError, "Ungelesene Journal-Einträge konnten nicht gelesen werden") + return + } + + operationIds = append(operationIds, operationID) + } + + writeJSON(w, http.StatusOK, map[string]any{ + "operationIds": operationIds, + }) +} + +func (s *Server) handleMarkOperationJournalRead(w http.ResponseWriter, r *http.Request) { + user, ok := userFromContext(r.Context()) + if !ok { + writeError(w, http.StatusUnauthorized, "Unauthorized") + return + } + + operationID := strings.TrimSpace(r.PathValue("id")) + if operationID == "" { + writeError(w, http.StatusBadRequest, "Einsatz-ID fehlt") + return + } + + _, err := s.db.Exec( + r.Context(), + ` + UPDATE notifications + SET read_at = COALESCE(read_at, now()) + WHERE user_id = $1 + AND visible = false + AND type = 'operation.journal' + AND entity_type = 'operation' + AND entity_id = $2 + AND read_at IS NULL + `, + user.ID, + operationID, + ) + + if err != nil { + writeError(w, http.StatusInternalServerError, "Journal-Status konnte nicht aktualisiert werden") + return + } + + writeJSON(w, http.StatusOK, map[string]any{ + "ok": true, + }) +} diff --git a/backend/routes.go b/backend/routes.go index c122b8a..6b6a71f 100644 --- a/backend/routes.go +++ b/backend/routes.go @@ -14,10 +14,64 @@ func (s *Server) Routes() http.Handler { mux.HandleFunc("POST /auth/logout", s.handleLogout) mux.HandleFunc("GET /auth/me", s.requireAuth(s.handleMe)) + mux.HandleFunc("GET /settings/sessions", s.requireAuth(s.handleListUserSessions)) + mux.HandleFunc("DELETE /settings/sessions/{id}", s.requireAuth(s.handleRevokeUserSession)) + mux.HandleFunc("PUT /settings/profile", s.requireAuth(s.handleUpdateSettingsProfile)) + mux.HandleFunc("PUT /settings/password", s.requireAuth(s.handleUpdateSettingsPassword)) + mux.HandleFunc("POST /settings/logout-other-sessions", s.requireAuth(s.handleLogoutOtherSessions)) + mux.HandleFunc("DELETE /settings/account", s.requireAuth(s.handleDeleteOwnAccount)) + mux.HandleFunc("GET /devices", s.requireAuth(s.handleListDevices)) mux.HandleFunc("POST /devices", s.requireAuth(s.handleCreateDevice)) mux.HandleFunc("PUT /devices/{id}", s.requireAuth(s.handleUpdateDevice)) mux.HandleFunc("GET /devices/{id}/history", s.requireAuth(s.handleListDeviceHistory)) + mux.HandleFunc("POST /devices/{id}/history", s.requireAuth(s.handleCreateDeviceJournalEntry)) + + mux.HandleFunc("GET /device-lookups", s.handleDeviceLookups) + mux.HandleFunc("POST /device-manufacturers", s.handleCreateDeviceManufacturer) + mux.HandleFunc("POST /device-locations", s.handleCreateDeviceLocation) + + mux.HandleFunc("GET /operations", s.requireAuth(s.requireRight("operations:read", s.handleListOperations))) + mux.HandleFunc("POST /operations", s.requireAuth(s.requireRight("operations:write", s.handleCreateOperation))) + mux.HandleFunc("PUT /operations/{id}", s.requireAuth(s.requireRight("operations:write", s.handleUpdateOperation))) + mux.HandleFunc("GET /operations/{id}/history", s.requireAuth(s.handleListOperationHistory)) + mux.HandleFunc("POST /operations/{id}/history", s.requireAuth(s.handleCreateOperationJournalEntry)) + mux.HandleFunc("PUT /operations/{id}/history/{historyId}", s.requireAuth(s.handleUpdateOperationJournalEntry)) + mux.HandleFunc("GET /operations/journal-unread", s.requireAuth(s.handleListUnreadOperationJournals)) + mux.HandleFunc("POST /operations/{id}/journal/read", s.requireAuth(s.handleMarkOperationJournalRead)) + + mux.HandleFunc("GET /search", s.requireAuth(s.handleGlobalSearch)) + + mux.HandleFunc("GET /address-search", s.requireAuth(s.handleAddressSearch)) + + mux.HandleFunc("GET /notifications", s.requireAuth(s.handleListNotifications)) + mux.HandleFunc("POST /notifications/{id}/read", s.requireAuth(s.handleMarkNotificationRead)) + mux.HandleFunc("POST /notifications/read-all", s.requireAuth(s.handleMarkAllNotificationsRead)) + + mux.HandleFunc("GET /events", s.requireAuth(s.handleNotificationEvents)) + + mux.HandleFunc("GET /dashboard/operation-changes", s.requireAuth(s.requireRight("operations:read", s.handleDashboardOperationChanges))) + + mux.HandleFunc("GET /dashboard/widgets", s.requireAuth(s.handleListDashboardWidgets)) + mux.HandleFunc("POST /dashboard/widgets", s.requireAuth(s.handleCreateDashboardWidget)) + mux.HandleFunc("PUT /dashboard/widgets/{id}", s.requireAuth(s.handleUpdateDashboardWidget)) + mux.HandleFunc("DELETE /dashboard/widgets/{id}", s.requireAuth(s.handleDeleteDashboardWidget)) + + /* admin settings */ + + mux.HandleFunc("GET /admin/users", s.requireAuth(s.requireRight("users:read", s.handleAdminListUsers))) + mux.HandleFunc("POST /admin/users", s.requireAuth(s.requireRight("users:write", s.handleAdminCreateUser))) + mux.HandleFunc("PUT /admin/users/{id}", s.requireAuth(s.requireRight("users:write", s.handleAdminUpdateUser))) + + mux.HandleFunc("GET /admin/teams", s.requireAuth(s.requireRight("teams:read", s.handleAdminListTeams))) + mux.HandleFunc("POST /admin/teams", s.requireAuth(s.requireRight("teams:write", s.handleAdminCreateTeam))) + mux.HandleFunc("PUT /admin/teams/{id}", s.requireAuth(s.requireRight("teams:write", s.handleAdminUpdateTeam))) + + /* milestone settings */ + + mux.HandleFunc("GET /admin/milestone", s.requireAuth(s.requireRight("administration:read", s.handleGetMilestoneSettings))) + mux.HandleFunc("PUT /admin/milestone", s.requireAuth(s.requireRight("administration:write", s.handleUpdateMilestoneSettings))) + mux.HandleFunc("POST /admin/milestone/token", s.requireAuth(s.requireRight("administration:write", s.handleFetchMilestoneToken))) return s.cors(mux) } diff --git a/backend/search.go b/backend/search.go new file mode 100644 index 0000000..46b8e94 --- /dev/null +++ b/backend/search.go @@ -0,0 +1,401 @@ +// backend\search.go + +package main + +import ( + "context" + "net/http" + "strconv" + "strings" +) + +type globalSearchResponse struct { + Query string `json:"query"` + Groups []globalSearchGroup `json:"groups"` +} + +type globalSearchGroup struct { + ID string `json:"id"` + Title string `json:"title"` + Items []globalSearchItem `json:"items"` +} + +type globalSearchItem struct { + ID string `json:"id"` + EntityID string `json:"entityId"` + EntityType string `json:"entityType"` + Title string `json:"title"` + Subtitle string `json:"subtitle"` + Href string `json:"href"` + Data map[string]any `json:"data,omitempty"` +} + +func (s *Server) handleGlobalSearch(w http.ResponseWriter, r *http.Request) { + user, ok := userFromContext(r.Context()) + if !ok { + writeError(w, http.StatusUnauthorized, "Unauthorized") + return + } + + query := strings.TrimSpace(r.URL.Query().Get("q")) + limit := parseSearchLimit(r.URL.Query().Get("limit"), 8) + + if len([]rune(query)) < 2 { + writeJSON(w, http.StatusOK, globalSearchResponse{ + Query: query, + Groups: []globalSearchGroup{}, + }) + return + } + + groups := make([]globalSearchGroup, 0, 3) + + if userCanSearch(user, "operations:read") { + items, err := s.searchOperations(r.Context(), query, limit) + if err != nil { + writeError(w, http.StatusInternalServerError, "Einsätze konnten nicht durchsucht werden") + return + } + + if len(items) > 0 { + groups = append(groups, globalSearchGroup{ + ID: "operations", + Title: "Einsätze", + Items: items, + }) + } + } + + if userCanSearch(user, "devices:read") { + items, err := s.searchDevices(r.Context(), query, limit) + if err != nil { + writeError(w, http.StatusInternalServerError, "Geräte konnten nicht durchsucht werden") + return + } + + if len(items) > 0 { + groups = append(groups, globalSearchGroup{ + ID: "devices", + Title: "Geräte", + Items: items, + }) + } + } + + if userCanSearch(user, "users:read") { + items, err := s.searchUsers(r.Context(), query, limit) + if err != nil { + writeError(w, http.StatusInternalServerError, "Benutzer konnten nicht durchsucht werden") + return + } + + if len(items) > 0 { + groups = append(groups, globalSearchGroup{ + ID: "users", + Title: "Benutzer", + Items: items, + }) + } + } + + writeJSON(w, http.StatusOK, globalSearchResponse{ + Query: query, + Groups: groups, + }) +} + +func (s *Server) searchOperations(ctx context.Context, query string, limit int) ([]globalSearchItem, error) { + pattern := "%" + query + "%" + + rows, err := s.db.Query( + ctx, + ` + SELECT + id::TEXT, + operation_number, + operation_name, + offense, + kw_address, + operation_leader, + operation_team + FROM operations + WHERE CONCAT_WS( + ' ', + operation_number, + operation_name, + offense, + case_worker, + target_object, + kw_address, + operation_leader, + operation_team, + legend, + remark + ) ILIKE $1 + ORDER BY updated_at DESC + LIMIT $2 + `, + pattern, + limit, + ) + if err != nil { + return nil, err + } + defer rows.Close() + + items := []globalSearchItem{} + + for rows.Next() { + var id string + var operationNumber string + var operationName string + var offense string + var kwAddress string + var operationLeader string + var operationTeam string + + if err := rows.Scan( + &id, + &operationNumber, + &operationName, + &offense, + &kwAddress, + &operationLeader, + &operationTeam, + ); err != nil { + return nil, err + } + + title := operationName + if strings.TrimSpace(title) == "" { + title = operationNumber + } + + subtitle := joinSearchSubtitle( + operationNumber, + offense, + kwAddress, + operationLeader, + operationTeam, + ) + + items = append(items, globalSearchItem{ + ID: "operation-" + id, + EntityID: id, + EntityType: "operation", + Title: title, + Subtitle: subtitle, + Href: "/einsaetze", + Data: map[string]any{ + "operationNumber": operationNumber, + }, + }) + } + + return items, rows.Err() +} + +func (s *Server) searchDevices(ctx context.Context, query string, limit int) ([]globalSearchItem, error) { + pattern := "%" + query + "%" + + rows, err := s.db.Query( + ctx, + ` + SELECT + id::TEXT, + inventory_number, + manufacturer, + model, + serial_number, + mac_address, + location, + loan_status + FROM devices + WHERE CONCAT_WS( + ' ', + inventory_number, + manufacturer, + model, + serial_number, + mac_address, + location, + loan_status, + comment + ) ILIKE $1 + ORDER BY updated_at DESC + LIMIT $2 + `, + pattern, + limit, + ) + if err != nil { + return nil, err + } + defer rows.Close() + + items := []globalSearchItem{} + + for rows.Next() { + var id string + var inventoryNumber string + var manufacturer string + var model string + var serialNumber string + var macAddress string + var location string + var loanStatus string + + if err := rows.Scan( + &id, + &inventoryNumber, + &manufacturer, + &model, + &serialNumber, + &macAddress, + &location, + &loanStatus, + ); err != nil { + return nil, err + } + + items = append(items, globalSearchItem{ + ID: "device-" + id, + EntityID: id, + EntityType: "device", + Title: inventoryNumber, + Subtitle: joinSearchSubtitle( + manufacturer, + model, + serialNumber, + macAddress, + location, + loanStatus, + ), + Href: "/geraete", + Data: map[string]any{ + "inventoryNumber": inventoryNumber, + }, + }) + } + + return items, rows.Err() +} + +func (s *Server) searchUsers(ctx context.Context, query string, limit int) ([]globalSearchItem, error) { + pattern := "%" + query + "%" + + rows, err := s.db.Query( + ctx, + ` + SELECT + id::TEXT, + COALESCE(username, ''), + COALESCE(display_name, ''), + email, + COALESCE(unit, ''), + COALESCE(user_group, '') + FROM users + WHERE CONCAT_WS( + ' ', + username, + display_name, + email, + unit, + user_group + ) ILIKE $1 + ORDER BY display_name ASC, username ASC, email ASC + LIMIT $2 + `, + pattern, + limit, + ) + if err != nil { + return nil, err + } + defer rows.Close() + + items := []globalSearchItem{} + + for rows.Next() { + var id string + var username string + var displayName string + var email string + var unit string + var group string + + if err := rows.Scan( + &id, + &username, + &displayName, + &email, + &unit, + &group, + ); err != nil { + return nil, err + } + + title := displayName + if strings.TrimSpace(title) == "" { + title = username + } + if strings.TrimSpace(title) == "" { + title = email + } + + items = append(items, globalSearchItem{ + ID: "user-" + id, + EntityID: id, + EntityType: "user", + Title: title, + Subtitle: joinSearchSubtitle(email, unit, group), + Href: "/administration", + Data: map[string]any{ + "username": username, + "email": email, + }, + }) + } + + return items, rows.Err() +} + +func parseSearchLimit(value string, fallback int) int { + limit, err := strconv.Atoi(value) + if err != nil { + return fallback + } + + if limit < 1 { + return fallback + } + + if limit > 25 { + return 25 + } + + return limit +} + +func userCanSearch(user User, right string) bool { + for _, userRight := range user.Rights { + if userRight == "admin" || userRight == right { + return true + } + } + + return false +} + +func joinSearchSubtitle(values ...string) string { + parts := make([]string, 0, len(values)) + + for _, value := range values { + value = strings.TrimSpace(value) + if value != "" { + parts = append(parts, value) + } + } + + return strings.Join(parts, " · ") +} diff --git a/backend/server.go b/backend/server.go index a44a688..fb5ad53 100644 --- a/backend/server.go +++ b/backend/server.go @@ -18,7 +18,10 @@ import ( type contextKey string -const userContextKey contextKey = "user" +const ( + userContextKey contextKey = "user" + sessionContextKey contextKey = "session" +) type Server struct { db *pgxpool.Pool @@ -27,15 +30,16 @@ type Server struct { } type User struct { - ID string `json:"id"` - Username string `json:"username"` - DisplayName string `json:"displayName"` - Email string `json:"email"` - Avatar string `json:"avatar"` - Unit string `json:"unit"` - Group string `json:"group"` - Rights []string `json:"rights"` - Teams []Team `json:"teams"` + ID string `json:"id"` + Username string `json:"username"` + DisplayName string `json:"displayName"` + Email string `json:"email"` + Avatar string `json:"avatar"` + Unit string `json:"unit"` + Group string `json:"group"` + Rights []string `json:"rights"` + Teams []Team `json:"teams"` + TokenVersion int `json:"-"` } type Team struct { @@ -46,25 +50,35 @@ type Team struct { Current bool `json:"current"` } +type ClientSessionInfoRequest struct { + Browser string `json:"browser"` + OperatingSystem string `json:"operatingSystem"` + DeviceType string `json:"deviceType"` +} + type RegisterRequest struct { - Username string `json:"username"` - DisplayName string `json:"displayName"` - Email string `json:"email"` - Password string `json:"password"` - Avatar string `json:"avatar"` - Unit string `json:"unit"` - Group string `json:"group"` - Rights []string `json:"rights"` + Username string `json:"username"` + DisplayName string `json:"displayName"` + Email string `json:"email"` + Password string `json:"password"` + Avatar string `json:"avatar"` + Unit string `json:"unit"` + Group string `json:"group"` + Rights []string `json:"rights"` + SessionInfo ClientSessionInfoRequest `json:"sessionInfo"` } type LoginRequest struct { - Identifier string `json:"identifier"` - Password string `json:"password"` + Identifier string `json:"identifier"` + Password string `json:"password"` + SessionInfo ClientSessionInfoRequest `json:"sessionInfo"` } type Claims struct { - UserID string `json:"user_id"` - Email string `json:"email"` + UserID string `json:"user_id"` + Email string `json:"email"` + TokenVersion int `json:"token_version"` + SessionID string `json:"session_id"` jwt.RegisteredClaims } @@ -143,7 +157,8 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) { COALESCE(avatar, ''), COALESCE(unit, ''), COALESCE(user_group, ''), - COALESCE(rights, '{}'::TEXT[]) + COALESCE(rights, '{}'::TEXT[]), + token_version `, username, displayName, @@ -162,6 +177,7 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) { &user.Unit, &user.Group, &user.Rights, + &user.TokenVersion, ) if err != nil { @@ -169,7 +185,18 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) { return } - if err := s.setSessionCookie(w, user); err != nil { + sessionID, err := s.createUserSession( + r.Context(), + user.ID, + input.SessionInfo, + r, + ) + if err != nil { + writeError(w, http.StatusInternalServerError, "Could not create session") + return + } + + if err := s.setSessionCookie(w, user, sessionID); err != nil { writeError(w, http.StatusInternalServerError, "Could not create session") return } @@ -211,7 +238,18 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { return } - if err := s.setSessionCookie(w, user); err != nil { + sessionID, err := s.createUserSession( + r.Context(), + user.ID, + input.SessionInfo, + r, + ) + if err != nil { + writeError(w, http.StatusInternalServerError, "Could not create session") + return + } + + if err := s.setSessionCookie(w, user, sessionID); err != nil { writeError(w, http.StatusInternalServerError, "Could not create session") return } @@ -222,6 +260,19 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { } func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) { + if sessionID, ok := s.currentSessionIDFromCookie(r); ok { + _, _ = s.db.Exec( + r.Context(), + ` + UPDATE user_sessions + SET revoked_at = now() + WHERE id = $1 + AND revoked_at IS NULL + `, + sessionID, + ) + } + http.SetCookie(w, &http.Cookie{ Name: "session", Value: "", @@ -250,12 +301,14 @@ func (s *Server) handleMe(w http.ResponseWriter, r *http.Request) { }) } -func (s *Server) setSessionCookie(w http.ResponseWriter, user User) error { +func (s *Server) setSessionCookie(w http.ResponseWriter, user User, sessionID string) error { now := time.Now() claims := Claims{ - UserID: user.ID, - Email: user.Email, + UserID: user.ID, + Email: user.Email, + TokenVersion: user.TokenVersion, + SessionID: sessionID, RegisteredClaims: jwt.RegisteredClaims{ Subject: user.ID, IssuedAt: jwt.NewNumericDate(now), @@ -277,9 +330,7 @@ func (s *Server) setSessionCookie(w http.ResponseWriter, user User) error { MaxAge: 60 * 60 * 24, HttpOnly: true, SameSite: http.SameSiteLaxMode, - - // In Produktion auf true setzen, wenn HTTPS aktiv ist. - Secure: false, + Secure: false, }) return nil @@ -314,7 +365,25 @@ func (s *Server) requireAuth(next http.HandlerFunc) http.HandlerFunc { return } + if claims.SessionID == "" { + writeError(w, http.StatusUnauthorized, "Unauthorized") + return + } + + sessionActive, err := s.isUserSessionActive( + r.Context(), + user.ID, + claims.SessionID, + ) + if err != nil || !sessionActive { + writeError(w, http.StatusUnauthorized, "Unauthorized") + return + } + + _ = s.touchUserSession(r.Context(), user.ID, claims.SessionID) + ctx := context.WithValue(r.Context(), userContextKey, user) + ctx = context.WithValue(ctx, sessionContextKey, claims.SessionID) next(w, r.WithContext(ctx)) } @@ -324,6 +393,8 @@ func (s *Server) getUserByIdentifier(ctx context.Context, identifier string) (Us var user User var passwordHash string + identifier = strings.ToLower(strings.TrimSpace(identifier)) + err := s.db.QueryRow( ctx, ` @@ -336,7 +407,8 @@ func (s *Server) getUserByIdentifier(ctx context.Context, identifier string) (Us COALESCE(avatar, ''), COALESCE(unit, ''), COALESCE(user_group, ''), - COALESCE(rights, '{}'::TEXT[]) + COALESCE(rights, '{}'::TEXT[]), + token_version FROM users WHERE lower(email) = $1 OR lower(username) = $1 LIMIT 1 @@ -352,6 +424,7 @@ func (s *Server) getUserByIdentifier(ctx context.Context, identifier string) (Us &user.Unit, &user.Group, &user.Rights, + &user.TokenVersion, ) if err != nil { @@ -382,7 +455,8 @@ func (s *Server) getUserByID(ctx context.Context, userID string) (User, error) { COALESCE(avatar, ''), COALESCE(unit, ''), COALESCE(user_group, ''), - COALESCE(rights, '{}'::TEXT[]) + COALESCE(rights, '{}'::TEXT[]), + token_version FROM users WHERE id = $1 LIMIT 1 @@ -397,6 +471,7 @@ func (s *Server) getUserByID(ctx context.Context, userID string) (User, error) { &user.Unit, &user.Group, &user.Rights, + &user.TokenVersion, ) if err != nil { diff --git a/backend/setup/main.go b/backend/setup/main.go index 4bf0da6..bd6ab7d 100644 --- a/backend/setup/main.go +++ b/backend/setup/main.go @@ -41,7 +41,7 @@ func main() { log.Fatal("DATABASE_URL enthält keinen Datenbanknamen") } - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) defer cancel() if *reset { @@ -177,21 +177,40 @@ func createSchema(ctx context.Context, db *pgxpool.Pool) error { ` CREATE TABLE IF NOT EXISTS users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + username TEXT UNIQUE, + display_name TEXT, + + email TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + + avatar TEXT, + unit TEXT, + user_group TEXT, + rights TEXT[] NOT NULL DEFAULT '{}'::TEXT[], + token_version INTEGER NOT NULL DEFAULT 1, + + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() + ) + `, + + ` + CREATE TABLE IF NOT EXISTS user_sessions ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - username TEXT UNIQUE, - display_name TEXT, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, - email TEXT NOT NULL UNIQUE, - password_hash TEXT NOT NULL, - - avatar TEXT, - unit TEXT, - user_group TEXT, - rights TEXT[] NOT NULL DEFAULT '{}'::TEXT[], + browser TEXT NOT NULL DEFAULT '', + operating_system TEXT NOT NULL DEFAULT '', + device_type TEXT NOT NULL DEFAULT '', + user_agent TEXT NOT NULL DEFAULT '', + ip_address TEXT NOT NULL DEFAULT '', created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT now() + last_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(), + revoked_at TIMESTAMPTZ ) `, @@ -219,6 +238,27 @@ func createSchema(ctx context.Context, db *pgxpool.Pool) error { ) `, + ` + CREATE TABLE IF NOT EXISTS dashboard_widgets ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + + type TEXT NOT NULL, + title TEXT NOT NULL DEFAULT '', + + x INTEGER NOT NULL DEFAULT 0, + y INTEGER NOT NULL DEFAULT 0, + w INTEGER NOT NULL DEFAULT 4, + h INTEGER NOT NULL DEFAULT 2, + + config JSONB NOT NULL DEFAULT '{}'::JSONB, + + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() + ) + `, + ` CREATE TABLE IF NOT EXISTS devices ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), @@ -230,6 +270,7 @@ func createSchema(ctx context.Context, db *pgxpool.Pool) error { mac_address TEXT NOT NULL DEFAULT '', location TEXT NOT NULL DEFAULT '', loan_status TEXT NOT NULL DEFAULT 'Verfügbar', + is_bao_device BOOLEAN NOT NULL DEFAULT false, comment TEXT NOT NULL DEFAULT '', created_at TIMESTAMPTZ NOT NULL DEFAULT now(), @@ -237,6 +278,30 @@ func createSchema(ctx context.Context, db *pgxpool.Pool) error { ) `, + ` + CREATE TABLE IF NOT EXISTS device_manufacturers ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + name TEXT NOT NULL, + normalized_name TEXT NOT NULL UNIQUE, + + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() + ) + `, + + ` + CREATE TABLE IF NOT EXISTS device_locations ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + name TEXT NOT NULL, + normalized_name TEXT NOT NULL UNIQUE, + + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() + ) + `, + ` CREATE TABLE IF NOT EXISTS device_relations ( device_id UUID NOT NULL REFERENCES devices(id) ON DELETE CASCADE, @@ -263,13 +328,106 @@ func createSchema(ctx context.Context, db *pgxpool.Pool) error { ) `, + ` + CREATE TABLE IF NOT EXISTS operations ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + operation_number TEXT NOT NULL, + operation_name TEXT NOT NULL DEFAULT '', + offense TEXT NOT NULL DEFAULT '', + case_worker TEXT NOT NULL DEFAULT '', + target_object TEXT NOT NULL DEFAULT '', + kw_address TEXT NOT NULL DEFAULT '', + operation_leader TEXT NOT NULL DEFAULT '', + operation_team TEXT NOT NULL DEFAULT '', + legend TEXT NOT NULL DEFAULT '', + camera TEXT NOT NULL DEFAULT '', + router TEXT NOT NULL DEFAULT '', + technology TEXT NOT NULL DEFAULT '', + switchbox TEXT NOT NULL DEFAULT '', + hard_drive TEXT NOT NULL DEFAULT '', + laptop TEXT NOT NULL DEFAULT '', + remark TEXT NOT NULL DEFAULT '', + + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() + ) + `, + + ` + CREATE TABLE IF NOT EXISTS operation_history ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + operation_id UUID NOT NULL REFERENCES operations(id) ON DELETE CASCADE, + user_id UUID REFERENCES users(id) ON DELETE SET NULL, + + action TEXT NOT NULL, + changes JSONB NOT NULL DEFAULT '[]'::JSONB, + + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() + ) + `, + + ` + CREATE TABLE IF NOT EXISTS notifications ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + + type TEXT NOT NULL DEFAULT 'info', + title TEXT NOT NULL, + message TEXT NOT NULL DEFAULT '', + visible BOOLEAN NOT NULL DEFAULT true, + + entity_type TEXT NOT NULL DEFAULT '', + entity_id UUID, + + data JSONB NOT NULL DEFAULT '{}'::JSONB, + + read_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() + ) + `, + + ` + CREATE TABLE IF NOT EXISTS milestone_settings ( + id INTEGER PRIMARY KEY DEFAULT 1 CHECK (id = 1), + + host TEXT NOT NULL DEFAULT '', + username TEXT NOT NULL DEFAULT '', + password_encrypted BYTEA, + skip_tls_verify BOOLEAN NOT NULL DEFAULT false, + + access_token_encrypted BYTEA, + token_type TEXT NOT NULL DEFAULT '', + token_expires_at TIMESTAMPTZ, + token_updated_at TIMESTAMPTZ, + + server_id TEXT NOT NULL DEFAULT '', + server_name TEXT NOT NULL DEFAULT '', + server_version TEXT NOT NULL DEFAULT '', + server_updated_at TIMESTAMPTZ, + + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() + ) + `, + `CREATE INDEX IF NOT EXISTS idx_users_email ON users(email)`, `CREATE INDEX IF NOT EXISTS idx_users_username ON users(username)`, + `CREATE INDEX IF NOT EXISTS idx_user_sessions_user_id ON user_sessions(user_id)`, + `CREATE INDEX IF NOT EXISTS idx_user_sessions_active ON user_sessions(user_id, revoked_at)`, + `CREATE INDEX IF NOT EXISTS idx_user_sessions_last_seen_at ON user_sessions(last_seen_at)`, + `CREATE INDEX IF NOT EXISTS idx_teams_name ON teams(name)`, `CREATE INDEX IF NOT EXISTS idx_user_teams_user_id ON user_teams(user_id)`, `CREATE INDEX IF NOT EXISTS idx_user_teams_team_id ON user_teams(team_id)`, + `CREATE INDEX IF NOT EXISTS idx_dashboard_widgets_user_id ON dashboard_widgets(user_id)`, + `CREATE INDEX IF NOT EXISTS idx_dashboard_widgets_position ON dashboard_widgets(user_id, y, x)`, + `CREATE UNIQUE INDEX IF NOT EXISTS idx_devices_inventory_number_unique ON devices(lower(inventory_number))`, `CREATE UNIQUE INDEX IF NOT EXISTS idx_devices_serial_number_unique ON devices(lower(serial_number)) WHERE serial_number <> ''`, `CREATE UNIQUE INDEX IF NOT EXISTS idx_devices_mac_address_unique ON devices(lower(mac_address)) WHERE mac_address <> ''`, @@ -279,12 +437,31 @@ func createSchema(ctx context.Context, db *pgxpool.Pool) error { `CREATE INDEX IF NOT EXISTS idx_devices_location ON devices(location)`, `CREATE INDEX IF NOT EXISTS idx_devices_loan_status ON devices(loan_status)`, + `CREATE INDEX IF NOT EXISTS idx_device_manufacturers_name ON device_manufacturers(name)`, + `CREATE INDEX IF NOT EXISTS idx_device_locations_name ON device_locations(name)`, + `CREATE INDEX IF NOT EXISTS idx_device_relations_device_id ON device_relations(device_id)`, `CREATE INDEX IF NOT EXISTS idx_device_relations_related_device_id ON device_relations(related_device_id)`, `CREATE INDEX IF NOT EXISTS idx_device_history_device_id ON device_history(device_id)`, `CREATE INDEX IF NOT EXISTS idx_device_history_user_id ON device_history(user_id)`, `CREATE INDEX IF NOT EXISTS idx_device_history_created_at ON device_history(created_at)`, + + `CREATE UNIQUE INDEX IF NOT EXISTS idx_operations_operation_number_unique ON operations(lower(operation_number))`, + `CREATE INDEX IF NOT EXISTS idx_operations_operation_name ON operations(operation_name)`, + `CREATE INDEX IF NOT EXISTS idx_operations_offense ON operations(offense)`, + `CREATE INDEX IF NOT EXISTS idx_operations_kw_address ON operations(kw_address)`, + `CREATE INDEX IF NOT EXISTS idx_operations_operation_leader ON operations(operation_leader)`, + `CREATE INDEX IF NOT EXISTS idx_operations_operation_team ON operations(operation_team)`, + + `CREATE INDEX IF NOT EXISTS idx_operation_history_operation_id ON operation_history(operation_id)`, + `CREATE INDEX IF NOT EXISTS idx_operation_history_user_id ON operation_history(user_id)`, + `CREATE INDEX IF NOT EXISTS idx_operation_history_created_at ON operation_history(created_at)`, + + `CREATE INDEX IF NOT EXISTS idx_notifications_user_id ON notifications(user_id)`, + `CREATE INDEX IF NOT EXISTS idx_notifications_created_at ON notifications(created_at)`, + `CREATE INDEX IF NOT EXISTS idx_notifications_read_at ON notifications(read_at)`, + `CREATE INDEX IF NOT EXISTS idx_notifications_entity ON notifications(entity_type, entity_id)`, } for _, query := range queries { @@ -305,7 +482,10 @@ func createAdminIfNotExists(ctx context.Context, db *pgxpool.Pool) (string, bool adminAvatar := env("ADMIN_AVATAR", "") adminUnit := env("ADMIN_UNIT", "Administration") adminGroup := env("ADMIN_GROUP", "admin") - adminRights := splitCSV(env("ADMIN_RIGHTS", "admin,users:read,users:write,teams:read,teams:write,devices:read,devices:write")) + adminRights := splitCSV(env( + "ADMIN_RIGHTS", + "admin,dashboard:read,settings:read,administration:read,administration:write,users:read,users:write,teams:read,teams:write,devices:read,devices:write,operations:read,operations:write,calendar:read,documents:read,reports:read", + )) adminTeamName := env("ADMIN_TEAM_NAME", "Administration") adminTeamInitial := env("ADMIN_TEAM_INITIAL", "A") diff --git a/backend/user_sessions.go b/backend/user_sessions.go new file mode 100644 index 0000000..c408eb4 --- /dev/null +++ b/backend/user_sessions.go @@ -0,0 +1,333 @@ +// backend\user_sessions.go + +package main + +import ( + "context" + "encoding/json" + "net" + "net/http" + "strings" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +type userSessionResponse struct { + ID string `json:"id"` + Browser string `json:"browser"` + OperatingSystem string `json:"operatingSystem"` + DeviceType string `json:"deviceType"` + UserAgent string `json:"userAgent"` + IPAddress string `json:"ipAddress"` + CreatedAt time.Time `json:"createdAt"` + LastSeenAt time.Time `json:"lastSeenAt"` + Current bool `json:"current"` +} + +func (s *Server) createUserSession( + ctx context.Context, + userID string, + input ClientSessionInfoRequest, + r *http.Request, +) (string, error) { + browser := strings.TrimSpace(input.Browser) + operatingSystem := strings.TrimSpace(input.OperatingSystem) + deviceType := strings.TrimSpace(input.DeviceType) + userAgent := r.UserAgent() + + if browser == "" { + browser = detectBrowserFromUserAgent(userAgent) + } + + if operatingSystem == "" { + operatingSystem = detectOperatingSystemFromUserAgent(userAgent) + } + + if deviceType == "" { + deviceType = detectDeviceTypeFromUserAgent(userAgent) + } + + var sessionID string + + err := s.db.QueryRow( + ctx, + ` + INSERT INTO user_sessions ( + user_id, + browser, + operating_system, + device_type, + user_agent, + ip_address + ) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING id + `, + userID, + browser, + operatingSystem, + deviceType, + userAgent, + getRequestIP(r), + ).Scan(&sessionID) + + return sessionID, err +} + +func (s *Server) isUserSessionActive( + ctx context.Context, + userID string, + sessionID string, +) (bool, error) { + var exists bool + + err := s.db.QueryRow( + ctx, + ` + SELECT EXISTS( + SELECT 1 + FROM user_sessions + WHERE id = $1 + AND user_id = $2 + AND revoked_at IS NULL + ) + `, + sessionID, + userID, + ).Scan(&exists) + + return exists, err +} + +func (s *Server) touchUserSession( + ctx context.Context, + userID string, + sessionID string, +) error { + _, err := s.db.Exec( + ctx, + ` + UPDATE user_sessions + SET last_seen_at = now() + WHERE id = $1 + AND user_id = $2 + AND revoked_at IS NULL + `, + sessionID, + userID, + ) + + return err +} + +func (s *Server) handleListUserSessions(w http.ResponseWriter, r *http.Request) { + userID, ok := s.currentUserIDFromRequest(r) + if !ok { + writeSettingsError(w, http.StatusUnauthorized, "Nicht angemeldet") + return + } + + currentSessionID, _ := s.currentSessionIDFromRequest(r) + + rows, err := s.db.Query( + r.Context(), + ` + SELECT + id, + browser, + operating_system, + device_type, + user_agent, + ip_address, + created_at, + last_seen_at + FROM user_sessions + WHERE user_id = $1 + AND revoked_at IS NULL + ORDER BY + CASE WHEN id = $2 THEN 0 ELSE 1 END, + last_seen_at DESC + `, + userID, + currentSessionID, + ) + if err != nil { + writeSettingsError(w, http.StatusInternalServerError, "Sitzungen konnten nicht geladen werden") + return + } + defer rows.Close() + + sessions := []userSessionResponse{} + + for rows.Next() { + var session userSessionResponse + + if err := rows.Scan( + &session.ID, + &session.Browser, + &session.OperatingSystem, + &session.DeviceType, + &session.UserAgent, + &session.IPAddress, + &session.CreatedAt, + &session.LastSeenAt, + ); err != nil { + writeSettingsError(w, http.StatusInternalServerError, "Sitzungen konnten nicht gelesen werden") + return + } + + session.Current = session.ID == currentSessionID + sessions = append(sessions, session) + } + + if err := rows.Err(); err != nil { + writeSettingsError(w, http.StatusInternalServerError, "Sitzungen konnten nicht gelesen werden") + return + } + + writeSettingsJSON(w, http.StatusOK, map[string]any{ + "sessions": sessions, + }) +} + +func (s *Server) handleRevokeUserSession(w http.ResponseWriter, r *http.Request) { + userID, ok := s.currentUserIDFromRequest(r) + if !ok { + writeSettingsError(w, http.StatusUnauthorized, "Nicht angemeldet") + return + } + + sessionID := r.PathValue("id") + currentSessionID, _ := s.currentSessionIDFromRequest(r) + + if sessionID == "" { + writeSettingsError(w, http.StatusBadRequest, "Sitzung fehlt") + return + } + + if sessionID == currentSessionID { + writeSettingsError(w, http.StatusBadRequest, "Die aktuelle Sitzung kann hier nicht abgemeldet werden") + return + } + + commandTag, err := s.db.Exec( + r.Context(), + ` + UPDATE user_sessions + SET revoked_at = now() + WHERE id = $1 + AND user_id = $2 + AND revoked_at IS NULL + `, + sessionID, + userID, + ) + if err != nil { + writeSettingsError(w, http.StatusInternalServerError, "Sitzung konnte nicht abgemeldet werden") + return + } + + if commandTag.RowsAffected() == 0 { + writeSettingsError(w, http.StatusNotFound, "Sitzung wurde nicht gefunden") + return + } + + writeSettingsJSON(w, http.StatusOK, map[string]any{ + "message": "Sitzung wurde abgemeldet", + }) +} + +func (s *Server) currentSessionIDFromRequest(r *http.Request) (string, bool) { + sessionID, ok := r.Context().Value(sessionContextKey).(string) + + return sessionID, ok && sessionID != "" +} + +func (s *Server) currentSessionIDFromCookie(r *http.Request) (string, bool) { + cookie, err := r.Cookie("session") + if err != nil { + return "", false + } + + claims := &Claims{} + + token, err := jwt.ParseWithClaims(cookie.Value, claims, func(token *jwt.Token) (any, error) { + return s.jwtSecret, nil + }) + if err != nil || !token.Valid || claims.SessionID == "" { + return "", false + } + + return claims.SessionID, true +} + +func getRequestIP(r *http.Request) string { + forwardedFor := r.Header.Get("X-Forwarded-For") + if forwardedFor != "" { + parts := strings.Split(forwardedFor, ",") + return strings.TrimSpace(parts[0]) + } + + realIP := r.Header.Get("X-Real-IP") + if realIP != "" { + return strings.TrimSpace(realIP) + } + + host, _, err := net.SplitHostPort(r.RemoteAddr) + if err == nil { + return host + } + + return r.RemoteAddr +} + +func detectBrowserFromUserAgent(userAgent string) string { + switch { + case strings.Contains(userAgent, "Edg/"): + return "Microsoft Edge" + case strings.Contains(userAgent, "Chrome/") && !strings.Contains(userAgent, "Edg/"): + return "Google Chrome" + case strings.Contains(userAgent, "Firefox/"): + return "Mozilla Firefox" + case strings.Contains(userAgent, "Safari/") && !strings.Contains(userAgent, "Chrome/"): + return "Safari" + default: + return "Unbekannter Browser" + } +} + +func detectOperatingSystemFromUserAgent(userAgent string) string { + switch { + case strings.Contains(userAgent, "Windows"): + return "Windows" + case strings.Contains(userAgent, "Mac OS X"): + return "macOS" + case strings.Contains(userAgent, "iPhone") || strings.Contains(userAgent, "iPad"): + return "iOS" + case strings.Contains(userAgent, "Android"): + return "Android" + case strings.Contains(userAgent, "Linux"): + return "Linux" + default: + return "Unbekanntes Betriebssystem" + } +} + +func detectDeviceTypeFromUserAgent(userAgent string) string { + switch { + case strings.Contains(userAgent, "iPhone") || strings.Contains(userAgent, "Android") && strings.Contains(userAgent, "Mobile"): + return "Smartphone" + case strings.Contains(userAgent, "iPad") || strings.Contains(userAgent, "Tablet"): + return "Tablet" + default: + return "Desktop" + } +} + +func writeSessionJSON(w http.ResponseWriter, status int, payload any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + + _ = json.NewEncoder(w).Encode(payload) +} diff --git a/backend/user_settings.go b/backend/user_settings.go new file mode 100644 index 0000000..7488aef --- /dev/null +++ b/backend/user_settings.go @@ -0,0 +1,379 @@ +// backend/user_settings.go + +package main + +import ( + "encoding/json" + "errors" + "net/http" + "strings" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" + "golang.org/x/crypto/bcrypt" +) + +type updateSettingsProfileRequest struct { + DisplayName string `json:"displayName"` + Username string `json:"username"` + Email string `json:"email"` + Avatar string `json:"avatar"` + Unit string `json:"unit"` + Group string `json:"group"` +} + +type updateSettingsPasswordRequest struct { + CurrentPassword string `json:"currentPassword"` + NewPassword string `json:"newPassword"` + ConfirmPassword string `json:"confirmPassword"` +} + +type confirmPasswordRequest struct { + Password string `json:"password"` +} + +func (s *Server) handleUpdateSettingsProfile(w http.ResponseWriter, r *http.Request) { + userID, ok := s.currentUserIDFromRequest(r) + if !ok { + writeSettingsError(w, http.StatusUnauthorized, "Nicht angemeldet") + return + } + + var payload updateSettingsProfileRequest + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + writeSettingsError(w, http.StatusBadRequest, "Ungültige Anfrage") + return + } + + username := normalizeUsername(payload.Username) + email := normalizeEmail(payload.Email) + displayName := strings.TrimSpace(payload.DisplayName) + avatar := strings.TrimSpace(payload.Avatar) + unit := strings.TrimSpace(payload.Unit) + group := strings.TrimSpace(payload.Group) + + if username == "" { + writeSettingsError(w, http.StatusBadRequest, "Benutzername ist erforderlich") + return + } + + if email == "" { + writeSettingsError(w, http.StatusBadRequest, "E-Mail-Adresse ist erforderlich") + return + } + + if displayName == "" { + displayName = username + } + + if group == "" { + group = "user" + } + + commandTag, err := s.db.Exec( + r.Context(), + ` + UPDATE users + SET + username = $1, + display_name = $2, + email = $3, + avatar = $4, + unit = $5, + user_group = $6, + updated_at = now() + WHERE id = $7 + `, + username, + displayName, + email, + avatar, + unit, + group, + userID, + ) + + if isPostgresUniqueViolation(err) { + writeSettingsError(w, http.StatusConflict, "Benutzername oder E-Mail-Adresse wird bereits verwendet") + return + } + + if err != nil { + writeSettingsError(w, http.StatusInternalServerError, "Profil konnte nicht gespeichert werden") + return + } + + if commandTag.RowsAffected() == 0 { + writeSettingsError(w, http.StatusNotFound, "Benutzer wurde nicht gefunden") + return + } + + user, err := s.getUserByID(r.Context(), userID) + if err != nil { + writeSettingsError(w, http.StatusInternalServerError, "Profil wurde gespeichert, konnte aber nicht neu geladen werden") + return + } + + writeSettingsJSON(w, http.StatusOK, map[string]any{ + "message": "Profil gespeichert", + "user": user, + }) +} + +func (s *Server) handleUpdateSettingsPassword(w http.ResponseWriter, r *http.Request) { + userID, ok := s.currentUserIDFromRequest(r) + if !ok { + writeSettingsError(w, http.StatusUnauthorized, "Nicht angemeldet") + return + } + + var payload updateSettingsPasswordRequest + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + writeSettingsError(w, http.StatusBadRequest, "Ungültige Anfrage") + return + } + + payload.CurrentPassword = strings.TrimSpace(payload.CurrentPassword) + payload.NewPassword = strings.TrimSpace(payload.NewPassword) + payload.ConfirmPassword = strings.TrimSpace(payload.ConfirmPassword) + + if payload.CurrentPassword == "" { + writeSettingsError(w, http.StatusBadRequest, "Aktuelles Passwort ist erforderlich") + return + } + + if len(payload.NewPassword) < 8 { + writeSettingsError(w, http.StatusBadRequest, "Das neue Passwort muss mindestens 8 Zeichen lang sein") + return + } + + if payload.NewPassword != payload.ConfirmPassword { + writeSettingsError(w, http.StatusBadRequest, "Die neuen Passwörter stimmen nicht überein") + return + } + + var currentPasswordHash string + + err := s.db.QueryRow( + r.Context(), + ` + SELECT password_hash + FROM users + WHERE id = $1 + LIMIT 1 + `, + userID, + ).Scan(¤tPasswordHash) + + if errors.Is(err, pgx.ErrNoRows) { + writeSettingsError(w, http.StatusNotFound, "Benutzer wurde nicht gefunden") + return + } + + if err != nil { + writeSettingsError(w, http.StatusInternalServerError, "Passwort konnte nicht geprüft werden") + return + } + + if err := bcrypt.CompareHashAndPassword([]byte(currentPasswordHash), []byte(payload.CurrentPassword)); err != nil { + writeSettingsError(w, http.StatusBadRequest, "Aktuelles Passwort ist nicht korrekt") + return + } + + nextPasswordHash, err := bcrypt.GenerateFromPassword([]byte(payload.NewPassword), bcrypt.DefaultCost) + if err != nil { + writeSettingsError(w, http.StatusInternalServerError, "Passwort konnte nicht vorbereitet werden") + return + } + + commandTag, err := s.db.Exec( + r.Context(), + ` + UPDATE users + SET + password_hash = $1, + updated_at = now() + WHERE id = $2 + `, + string(nextPasswordHash), + userID, + ) + + if err != nil { + writeSettingsError(w, http.StatusInternalServerError, "Passwort konnte nicht gespeichert werden") + return + } + + if commandTag.RowsAffected() == 0 { + writeSettingsError(w, http.StatusNotFound, "Benutzer wurde nicht gefunden") + return + } + + writeSettingsJSON(w, http.StatusOK, map[string]any{ + "message": "Passwort gespeichert", + }) +} + +func (s *Server) handleLogoutOtherSessions(w http.ResponseWriter, r *http.Request) { + userID, ok := s.currentUserIDFromRequest(r) + if !ok { + writeSettingsError(w, http.StatusUnauthorized, "Nicht angemeldet") + return + } + + currentSessionID, ok := s.currentSessionIDFromRequest(r) + if !ok { + writeSettingsError(w, http.StatusUnauthorized, "Aktuelle Sitzung wurde nicht gefunden") + return + } + + var payload confirmPasswordRequest + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + writeSettingsError(w, http.StatusBadRequest, "Ungültige Anfrage") + return + } + + if err := s.verifyUserPassword(r, userID, payload.Password); err != nil { + writeSettingsError(w, http.StatusBadRequest, "Passwort ist nicht korrekt") + return + } + + _, err := s.db.Exec( + r.Context(), + ` + UPDATE user_sessions + SET revoked_at = now() + WHERE user_id = $1 + AND id <> $2 + AND revoked_at IS NULL + `, + userID, + currentSessionID, + ) + if err != nil { + writeSettingsError(w, http.StatusInternalServerError, "Andere Sitzungen konnten nicht abgemeldet werden") + return + } + + writeSettingsJSON(w, http.StatusOK, map[string]any{ + "message": "Andere Sitzungen wurden abgemeldet", + }) +} + +func (s *Server) handleDeleteOwnAccount(w http.ResponseWriter, r *http.Request) { + userID, ok := s.currentUserIDFromRequest(r) + if !ok { + writeSettingsError(w, http.StatusUnauthorized, "Nicht angemeldet") + return + } + + var payload confirmPasswordRequest + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + writeSettingsError(w, http.StatusBadRequest, "Ungültige Anfrage") + return + } + + if err := s.verifyUserPassword(r, userID, payload.Password); err != nil { + writeSettingsError(w, http.StatusBadRequest, "Passwort ist nicht korrekt") + return + } + + tx, err := s.db.Begin(r.Context()) + if err != nil { + writeSettingsError(w, http.StatusInternalServerError, "Konto konnte nicht gelöscht werden") + return + } + defer tx.Rollback(r.Context()) + + commandTag, err := tx.Exec( + r.Context(), + ` + DELETE FROM users + WHERE id = $1 + `, + userID, + ) + if err != nil { + writeSettingsError(w, http.StatusInternalServerError, "Konto konnte nicht gelöscht werden") + return + } + + if commandTag.RowsAffected() == 0 { + writeSettingsError(w, http.StatusNotFound, "Benutzer wurde nicht gefunden") + return + } + + if err := tx.Commit(r.Context()); err != nil { + writeSettingsError(w, http.StatusInternalServerError, "Konto konnte nicht gelöscht werden") + return + } + + http.SetCookie(w, &http.Cookie{ + Name: "session", + Value: "", + Path: "/", + MaxAge: -1, + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + Secure: false, + }) + + writeSettingsJSON(w, http.StatusOK, map[string]any{ + "message": "Konto gelöscht", + }) +} + +func (s *Server) verifyUserPassword(r *http.Request, userID string, password string) error { + password = strings.TrimSpace(password) + + if password == "" { + return errors.New("password required") + } + + var passwordHash string + + err := s.db.QueryRow( + r.Context(), + ` + SELECT password_hash + FROM users + WHERE id = $1 + LIMIT 1 + `, + userID, + ).Scan(&passwordHash) + if err != nil { + return err + } + + return bcrypt.CompareHashAndPassword([]byte(passwordHash), []byte(password)) +} + +func (s *Server) currentUserIDFromRequest(r *http.Request) (string, bool) { + user, ok := r.Context().Value(userContextKey).(User) + + if !ok || user.ID == "" { + return "", false + } + + return user.ID, true +} + +func isPostgresUniqueViolation(err error) bool { + var pgErr *pgconn.PgError + + return errors.As(err, &pgErr) && pgErr.Code == "23505" +} + +func writeSettingsJSON(w http.ResponseWriter, status int, payload any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + + _ = json.NewEncoder(w).Encode(payload) +} + +func writeSettingsError(w http.ResponseWriter, status int, message string) { + writeSettingsJSON(w, status, map[string]any{ + "error": message, + }) +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 8cf2ff7..b390f98 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,16 +8,21 @@ "name": "teg", "version": "0.0.0", "dependencies": { + "@arcgis/core": "^5.0.19", "@headlessui/react": "^2.2.10", "@heroicons/react": "^2.2.0", "@tailwindcss/vite": "^4.3.0", + "leaflet": "^1.9.4", + "leaflet.heat": "^0.2.0", "react": "^19.2.6", "react-dom": "^19.2.6", + "react-leaflet": "^5.0.0", "react-router": "^7.15.0", "tailwindcss": "^4.3.0" }, "devDependencies": { "@eslint/js": "^10.0.1", + "@types/leaflet": "^1.9.21", "@types/node": "^24.12.3", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", @@ -31,6 +36,85 @@ "vite": "^8.0.12" } }, + "node_modules/@amcharts/amcharts5": { + "version": "5.15.6", + "resolved": "https://registry.npmjs.org/@amcharts/amcharts5/-/amcharts5-5.15.6.tgz", + "integrity": "sha512-6gVWngHgkMN+v6dJhZ4XJE3fcHjvD5hyjgyNnfvVE1H0ukmHpfFmhI7cCX0cgy0NRyZc4fwgxDMZsiNA7yYKjA==", + "license": "SEE LICENSE IN LICENSE", + "dependencies": { + "@types/d3": "^7.0.0", + "@types/d3-chord": "^3.0.0", + "@types/d3-hierarchy": "3.1.1", + "@types/d3-sankey": "^0.11.1", + "@types/d3-shape": "^3.0.0", + "@types/geojson": "^7946.0.8", + "@types/polylabel": "^1.0.5", + "@types/svg-arc-to-cubic-bezier": "^3.2.0", + "d3": "^7.0.0", + "d3-chord": "^3.0.0", + "d3-force": "^3.0.0", + "d3-geo": "^3.0.0", + "d3-hierarchy": "^3.0.0", + "d3-sankey": "^0.12.3", + "d3-selection": "^3.0.0", + "d3-shape": "^3.0.0", + "d3-transition": "^3.0.0", + "d3-voronoi-treemap": "^1.1.2", + "flatpickr": "^4.6.13", + "markerjs2": "^2.29.4", + "pdfmake": "^0.2.2", + "polylabel": "^1.1.0", + "seedrandom": "^3.0.5", + "svg-arc-to-cubic-bezier": "^3.2.0", + "tslib": "^2.2.0" + } + }, + "node_modules/@arcgis/core": { + "version": "5.0.19", + "resolved": "https://registry.npmjs.org/@arcgis/core/-/core-5.0.19.tgz", + "integrity": "sha512-OciZxzB16sTxtyfESqjuTC6rkMpwcmAzx2iQip3r1NE0IW869Eo5beyonILcHGCx9werUUd/5UhgmOI9MlX4lw==", + "license": "SEE LICENSE IN LICENSE.md", + "dependencies": { + "@amcharts/amcharts5": "~5.15.5", + "@arcgis/toolkit": "^5.0.0", + "@esri/arcgis-html-sanitizer": "~4.1.0", + "@esri/calcite-components": "^5.0.2", + "@vaadin/grid": "~25.0.3", + "@zip.js/zip.js": "~2.8.16", + "luxon": "~3.7.2", + "marked": "~17.0.3", + "tslib": "^2.8.1" + } + }, + "node_modules/@arcgis/lumina": { + "version": "5.0.19", + "resolved": "https://registry.npmjs.org/@arcgis/lumina/-/lumina-5.0.19.tgz", + "integrity": "sha512-L2WL/yYfR+xY8wpnhObxC/MCaOgImyrvZo5SbBm4tCJtfnpkzpjfGfQ5B5Wj2t+erWxpqVdNBBlQtmq9sipj5g==", + "license": "SEE LICENSE IN LICENSE.md", + "dependencies": { + "@arcgis/toolkit": "~5.0.19", + "csstype": "^3.1.3", + "tslib": "^2.8.1" + }, + "peerDependencies": { + "@lit/context": "^1.1.6", + "lit": "^3.3.0" + }, + "peerDependenciesMeta": { + "@lit/context": { + "optional": true + } + } + }, + "node_modules/@arcgis/toolkit": { + "version": "5.0.19", + "resolved": "https://registry.npmjs.org/@arcgis/toolkit/-/toolkit-5.0.19.tgz", + "integrity": "sha512-764RD+gII0r6o5tVXB8W5p+eOpjnfmlFnL/AXw4oPvby1PpwiSlFyhhzpjJIz7SD9ENn+gmK8+E6jMLT8Y1RFA==", + "license": "SEE LICENSE IN LICENSE.md", + "dependencies": { + "tslib": "^2.8.1" + } + }, "node_modules/@babel/code-frame": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", @@ -430,6 +514,50 @@ "node": "^20.19.0 || ^22.13.0 || >=24" } }, + "node_modules/@esri/arcgis-html-sanitizer": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@esri/arcgis-html-sanitizer/-/arcgis-html-sanitizer-4.1.0.tgz", + "integrity": "sha512-einEveDJ/k1180NOp78PB/4Hje9eBy3dyOGLLtLn6bSkizpUfCwuYBIXOA7Y3F/k/BsTQXgKqUVwQ0eiscWMdA==", + "license": "Apache-2.0", + "dependencies": { + "xss": "1.0.13" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@esri/calcite-components": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@esri/calcite-components/-/calcite-components-5.0.2.tgz", + "integrity": "sha512-AE8AQBsdpWWAPJEBc/nabJbdN+H+iuVBJv215mm8xjlyqpHd6BLsD8nG/K8IywDFHeB9TyHLQdYCk/sNiyQo7w==", + "license": "SEE LICENSE.md", + "dependencies": { + "@arcgis/lumina": ">=5.0.0-next.144 <6.0.0", + "@arcgis/toolkit": ">=5.0.0-next.144 <6.0.0", + "@esri/calcite-ui-icons": "4.4.0", + "@floating-ui/dom": "^1.6.12", + "@floating-ui/utils": "^0.2.8", + "@types/sortablejs": "^1.15.8", + "color": "^5.0.3", + "composed-offset-position": "^0.0.6", + "es-toolkit": "^1.39.8", + "focus-trap": "^7.6.5", + "interactjs": "^1.10.27", + "lit": "^3.3.0", + "sortablejs": "^1.15.6", + "timezone-groups": "^0.10.4", + "type-fest": "^4.30.1" + } + }, + "node_modules/@esri/calcite-ui-icons": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/@esri/calcite-ui-icons/-/calcite-ui-icons-4.4.0.tgz", + "integrity": "sha512-PcCGId6vKBysRuuoCIYrVsaC+YVv1lFMqVUQn3Qna0BHOIyf7Mursf3NStELNjYdmL0MRYfmLDEDYQwnAJ/tSA==", + "license": "SEE LICENSE.md", + "bin": { + "spriter": "bin/spriter.js" + } + }, "node_modules/@floating-ui/core": { "version": "1.7.5", "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", @@ -483,6 +611,51 @@ "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", "license": "MIT" }, + "node_modules/@foliojs-fork/fontkit": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@foliojs-fork/fontkit/-/fontkit-1.9.2.tgz", + "integrity": "sha512-IfB5EiIb+GZk+77TRB86AHroVaqfq8JRFlUbz0WEwsInyCG0epX2tCPOy+UfaWPju30DeVoUAXfzWXmhn753KA==", + "license": "MIT", + "dependencies": { + "@foliojs-fork/restructure": "^2.0.2", + "brotli": "^1.2.0", + "clone": "^1.0.4", + "deep-equal": "^1.0.0", + "dfa": "^1.2.0", + "tiny-inflate": "^1.0.2", + "unicode-properties": "^1.2.2", + "unicode-trie": "^2.0.0" + } + }, + "node_modules/@foliojs-fork/linebreak": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@foliojs-fork/linebreak/-/linebreak-1.1.2.tgz", + "integrity": "sha512-ZPohpxxbuKNE0l/5iBJnOAfUaMACwvUIKCvqtWGKIMv1lPYoNjYXRfhi9FeeV9McBkBLxsMFWTVVhHJA8cyzvg==", + "license": "MIT", + "dependencies": { + "base64-js": "1.3.1", + "unicode-trie": "^2.0.0" + } + }, + "node_modules/@foliojs-fork/pdfkit": { + "version": "0.15.3", + "resolved": "https://registry.npmjs.org/@foliojs-fork/pdfkit/-/pdfkit-0.15.3.tgz", + "integrity": "sha512-Obc0Wmy3bm7BINFVvPhcl2rnSSK61DQrlHU8aXnAqDk9LCjWdUOPwhgD8Ywz5VtuFjRxmVOM/kQ/XLIBjDvltw==", + "license": "MIT", + "dependencies": { + "@foliojs-fork/fontkit": "^1.9.2", + "@foliojs-fork/linebreak": "^1.1.1", + "crypto-js": "^4.2.0", + "jpeg-exif": "^1.1.4", + "png-js": "^1.0.0" + } + }, + "node_modules/@foliojs-fork/restructure": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@foliojs-fork/restructure/-/restructure-2.0.2.tgz", + "integrity": "sha512-59SgoZ3EXbkfSX7b63tsou/SDGzwUEK6MuB5sKqgVK1/XE0fxmpsOb9DQI8LXW3KfGnAjImCGhhEb7uPPAUVNA==", + "license": "MIT" + }, "node_modules/@headlessui/react": { "version": "2.2.10", "resolved": "https://registry.npmjs.org/@headlessui/react/-/react-2.2.10.tgz", @@ -578,6 +751,12 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@interactjs/types": { + "version": "1.10.27", + "resolved": "https://registry.npmjs.org/@interactjs/types/-/types-1.10.27.tgz", + "integrity": "sha512-BUdv0cvs4H5ODuwft2Xp4eL8Vmi3LcihK42z0Ft/FbVJZoRioBsxH+LlsBdK4tAie7PqlKGy+1oyOncu1nQ6eA==", + "license": "MIT" + }, "node_modules/@internationalized/date": { "version": "3.12.1", "resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.12.1.tgz", @@ -650,6 +829,21 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@lit-labs/ssr-dom-shim": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@lit-labs/ssr-dom-shim/-/ssr-dom-shim-1.5.1.tgz", + "integrity": "sha512-Aou5UdlSpr5whQe8AA/bZG0jMj96CoJIWbGfZ91qieWu5AWUMKw8VR/pAkQkJYvBNhmCcWnZlyyk5oze8JIqYA==", + "license": "BSD-3-Clause" + }, + "node_modules/@lit/reactive-element": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@lit/reactive-element/-/reactive-element-2.1.2.tgz", + "integrity": "sha512-pbCDiVMnne1lYUIaYNN5wrwQXDtHaYtg7YEFPeW+hws6U47WeFvISGUWekPGKWOP1ygrs0ef0o1VJMk1exos5A==", + "license": "BSD-3-Clause", + "dependencies": { + "@lit-labs/ssr-dom-shim": "^1.5.0" + } + }, "node_modules/@napi-rs/wasm-runtime": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", @@ -668,6 +862,12 @@ "@emnapi/runtime": "^1.7.1" } }, + "node_modules/@open-wc/dedupe-mixin": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@open-wc/dedupe-mixin/-/dedupe-mixin-1.4.0.tgz", + "integrity": "sha512-Sj7gKl1TLcDbF7B6KUhtvr+1UCxdhMbNY5KxdU5IfMFWqL8oy1ZeAcCANjoB1TL0AJTcPmcCFsCbHf8X2jGDUA==", + "license": "MIT" + }, "node_modules/@oxc-project/types": { "version": "0.129.0", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.129.0.tgz", @@ -706,6 +906,17 @@ "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, + "node_modules/@react-leaflet/core": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@react-leaflet/core/-/core-3.0.0.tgz", + "integrity": "sha512-3EWmekh4Nz+pGcr+xjf0KNyYfC3U2JjnkWsh0zcqaexYqmmB5ZhH37kz41JXGmKzpaMZCnPofBBm64i+YrEvGQ==", + "license": "Hippocratic-2.1", + "peerDependencies": { + "leaflet": "^1.9.0", + "react": "^19.0.0", + "react-dom": "^19.0.0" + } + }, "node_modules/@react-types/shared": { "version": "3.34.0", "resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.34.0.tgz", @@ -1297,6 +1508,283 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/d3": { + "version": "7.4.3", + "resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz", + "integrity": "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==", + "license": "MIT", + "dependencies": { + "@types/d3-array": "*", + "@types/d3-axis": "*", + "@types/d3-brush": "*", + "@types/d3-chord": "*", + "@types/d3-color": "*", + "@types/d3-contour": "*", + "@types/d3-delaunay": "*", + "@types/d3-dispatch": "*", + "@types/d3-drag": "*", + "@types/d3-dsv": "*", + "@types/d3-ease": "*", + "@types/d3-fetch": "*", + "@types/d3-force": "*", + "@types/d3-format": "*", + "@types/d3-geo": "*", + "@types/d3-hierarchy": "*", + "@types/d3-interpolate": "*", + "@types/d3-path": "*", + "@types/d3-polygon": "*", + "@types/d3-quadtree": "*", + "@types/d3-random": "*", + "@types/d3-scale": "*", + "@types/d3-scale-chromatic": "*", + "@types/d3-selection": "*", + "@types/d3-shape": "*", + "@types/d3-time": "*", + "@types/d3-time-format": "*", + "@types/d3-timer": "*", + "@types/d3-transition": "*", + "@types/d3-zoom": "*" + } + }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-axis": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-axis/-/d3-axis-3.0.6.tgz", + "integrity": "sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-brush": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-brush/-/d3-brush-3.0.6.tgz", + "integrity": "sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-chord": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-chord/-/d3-chord-3.0.6.tgz", + "integrity": "sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-contour": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-contour/-/d3-contour-3.0.6.tgz", + "integrity": "sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==", + "license": "MIT", + "dependencies": { + "@types/d3-array": "*", + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-delaunay": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==", + "license": "MIT" + }, + "node_modules/@types/d3-dispatch": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-dispatch/-/d3-dispatch-3.0.7.tgz", + "integrity": "sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==", + "license": "MIT" + }, + "node_modules/@types/d3-drag": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz", + "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-dsv": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-dsv/-/d3-dsv-3.0.7.tgz", + "integrity": "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-fetch": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-fetch/-/d3-fetch-3.0.7.tgz", + "integrity": "sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==", + "license": "MIT", + "dependencies": { + "@types/d3-dsv": "*" + } + }, + "node_modules/@types/d3-force": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@types/d3-force/-/d3-force-3.0.10.tgz", + "integrity": "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==", + "license": "MIT" + }, + "node_modules/@types/d3-format": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.4.tgz", + "integrity": "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==", + "license": "MIT" + }, + "node_modules/@types/d3-geo": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.0.tgz", + "integrity": "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==", + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-hierarchy": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-hierarchy/-/d3-hierarchy-3.1.1.tgz", + "integrity": "sha512-QwjxA3+YCKH3N1Rs3uSiSy1bdxlLB1uUiENXeJudBoAFvtDuswUxLcanoOaR2JYn1melDTuIXR8VhnVyI3yG/A==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-polygon": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-polygon/-/d3-polygon-3.0.2.tgz", + "integrity": "sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==", + "license": "MIT" + }, + "node_modules/@types/d3-quadtree": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-quadtree/-/d3-quadtree-3.0.6.tgz", + "integrity": "sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==", + "license": "MIT" + }, + "node_modules/@types/d3-random": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/d3-random/-/d3-random-3.0.3.tgz", + "integrity": "sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ==", + "license": "MIT" + }, + "node_modules/@types/d3-sankey": { + "version": "0.11.2", + "resolved": "https://registry.npmjs.org/@types/d3-sankey/-/d3-sankey-0.11.2.tgz", + "integrity": "sha512-U6SrTWUERSlOhnpSrgvMX64WblX1AxX6nEjI2t3mLK2USpQrnbwYYK+AS9SwiE7wgYmOsSSKoSdr8aoKBH0HgQ==", + "license": "MIT", + "dependencies": { + "@types/d3-shape": "^1" + } + }, + "node_modules/@types/d3-sankey/node_modules/@types/d3-path": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-1.0.11.tgz", + "integrity": "sha512-4pQMp8ldf7UaB/gR8Fvvy69psNHkTpD/pVw3vmEi8iZAB9EPMBruB1JvHO4BIq9QkUUd2lV1F5YXpMNj7JPBpw==", + "license": "MIT" + }, + "node_modules/@types/d3-sankey/node_modules/@types/d3-shape": { + "version": "1.3.12", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-1.3.12.tgz", + "integrity": "sha512-8oMzcd4+poSLGgV0R1Q1rOlx/xdmozS4Xab7np0eamFFUYq71AU9pOCJEFnkXW2aI/oXdVYJzw6pssbSut7Z9Q==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "^1" + } + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==", + "license": "MIT" + }, + "node_modules/@types/d3-selection": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz", + "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==", + "license": "MIT" + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-time-format": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-4.0.3.tgz", + "integrity": "sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, + "node_modules/@types/d3-transition": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz", + "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-zoom": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", + "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==", + "license": "MIT", + "dependencies": { + "@types/d3-interpolate": "*", + "@types/d3-selection": "*" + } + }, "node_modules/@types/esrecurse": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", @@ -1311,6 +1799,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "license": "MIT" + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -1318,6 +1812,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/leaflet": { + "version": "1.9.21", + "resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.21.tgz", + "integrity": "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, "node_modules/@types/node": { "version": "24.12.4", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.4.tgz", @@ -1328,6 +1832,12 @@ "undici-types": "~7.16.0" } }, + "node_modules/@types/polylabel": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@types/polylabel/-/polylabel-1.1.3.tgz", + "integrity": "sha512-9Zw2KoDpi+T4PZz2G6pO2xArE0m/GSMTW1MIxF2s8ZY8x9XDO6fv9um0ydRGvcbkFLlaq8yNK6eZxnmMZtDgWQ==", + "license": "MIT" + }, "node_modules/@types/react": { "version": "19.2.14", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", @@ -1348,6 +1858,24 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/sortablejs": { + "version": "1.15.9", + "resolved": "https://registry.npmjs.org/@types/sortablejs/-/sortablejs-1.15.9.tgz", + "integrity": "sha512-7HP+rZGE2p886PKV9c9OJzLBI6BBJu1O7lJGYnPyG3fS4/duUCcngkNCjsLwIMV+WMqANe3tt4irrXHSIe68OQ==", + "license": "MIT" + }, + "node_modules/@types/svg-arc-to-cubic-bezier": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/@types/svg-arc-to-cubic-bezier/-/svg-arc-to-cubic-bezier-3.2.3.tgz", + "integrity": "sha512-UNOnbTtl0nVTm8hwKaz5R5VZRvSulFMGojO5+Q7yucKxBoCaTtS4ibSQVRHo5VW5AaRo145U8p1Vfg5KrYe9Bg==", + "license": "MIT" + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.59.3", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.3.tgz", @@ -1591,6 +2119,136 @@ "url": "https://opencollective.com/typescript-eslint" } }, + "node_modules/@vaadin/a11y-base": { + "version": "25.0.12", + "resolved": "https://registry.npmjs.org/@vaadin/a11y-base/-/a11y-base-25.0.12.tgz", + "integrity": "sha512-ie5au1EoNIWOaLEroBF2K8V2PN9G+U52RVVGxEw8mWbe2RS537yac8wKk4tYGZgtv2pbNZSqzzW+/uxO2X5WgQ==", + "license": "Apache-2.0", + "dependencies": { + "@open-wc/dedupe-mixin": "^1.3.0", + "@vaadin/component-base": "~25.0.12", + "lit": "^3.0.0" + } + }, + "node_modules/@vaadin/checkbox": { + "version": "25.0.12", + "resolved": "https://registry.npmjs.org/@vaadin/checkbox/-/checkbox-25.0.12.tgz", + "integrity": "sha512-s9eHzZLqyxsj2CVzEksqJDttU2Mues7nDncx5KhUWV/Gk60lFXVvDfkQ9iqbvxOLCEC6VMvm1fme2XT6SgTdYg==", + "license": "Apache-2.0", + "dependencies": { + "@open-wc/dedupe-mixin": "^1.3.0", + "@vaadin/a11y-base": "~25.0.12", + "@vaadin/component-base": "~25.0.12", + "@vaadin/field-base": "~25.0.12", + "@vaadin/vaadin-themable-mixin": "~25.0.12", + "lit": "^3.0.0" + } + }, + "node_modules/@vaadin/component-base": { + "version": "25.0.12", + "resolved": "https://registry.npmjs.org/@vaadin/component-base/-/component-base-25.0.12.tgz", + "integrity": "sha512-xoW/ssKuRO5ye9L0f8Ypwc3AcREF1qKgWHQ4qYa3KqfmmK0u+IvArCaXr6OpbjpOfK2nUSCBRFUyxQNH/9ISZA==", + "license": "Apache-2.0", + "dependencies": { + "@open-wc/dedupe-mixin": "^1.3.0", + "@vaadin/vaadin-development-mode-detector": "^2.0.0", + "@vaadin/vaadin-usage-statistics": "^2.1.0", + "lit": "^3.0.0" + } + }, + "node_modules/@vaadin/field-base": { + "version": "25.0.12", + "resolved": "https://registry.npmjs.org/@vaadin/field-base/-/field-base-25.0.12.tgz", + "integrity": "sha512-/9aHsdGiAme63Ogg99XkSGn+nPeDxu/rZv0iRR0QpzNf/Da+IcQan56xg9NZ4dlR5RX1sjC8pmX22a9OjSm0Cw==", + "license": "Apache-2.0", + "dependencies": { + "@open-wc/dedupe-mixin": "^1.3.0", + "@vaadin/a11y-base": "~25.0.12", + "@vaadin/component-base": "~25.0.12", + "lit": "^3.0.0" + } + }, + "node_modules/@vaadin/grid": { + "version": "25.0.12", + "resolved": "https://registry.npmjs.org/@vaadin/grid/-/grid-25.0.12.tgz", + "integrity": "sha512-IgX6L+twLxe3tA8VsWlYhTW48qfALDqQ9aDqsds6/z2ADn6i8dl39T2Dd83ayY3Oc/AROHV+M2ns6POmUwhZ0Q==", + "license": "Apache-2.0", + "dependencies": { + "@open-wc/dedupe-mixin": "^1.3.0", + "@vaadin/a11y-base": "~25.0.12", + "@vaadin/checkbox": "~25.0.12", + "@vaadin/component-base": "~25.0.12", + "@vaadin/lit-renderer": "~25.0.12", + "@vaadin/text-field": "~25.0.12", + "@vaadin/vaadin-themable-mixin": "~25.0.12", + "lit": "^3.0.0" + } + }, + "node_modules/@vaadin/input-container": { + "version": "25.0.12", + "resolved": "https://registry.npmjs.org/@vaadin/input-container/-/input-container-25.0.12.tgz", + "integrity": "sha512-csshn16IIkN7oqyM64r6o7UIoiLaKaC/1C9GJMyUDUafzCAPUSg/nq9+2TwIWzIcY2+vE8atOCJWgxlhmikv+A==", + "license": "Apache-2.0", + "dependencies": { + "@vaadin/component-base": "~25.0.12", + "@vaadin/vaadin-themable-mixin": "~25.0.12", + "lit": "^3.0.0" + } + }, + "node_modules/@vaadin/lit-renderer": { + "version": "25.0.12", + "resolved": "https://registry.npmjs.org/@vaadin/lit-renderer/-/lit-renderer-25.0.12.tgz", + "integrity": "sha512-G1E7ooFhFpWUkp3dWunv2CYW4pwz0ADvSSETkQCnnIxBBn8vzfXrkloCGBYWmZachtAM/UdHnuVoue9FFsJzSg==", + "license": "Apache-2.0", + "dependencies": { + "lit": "^3.0.0" + } + }, + "node_modules/@vaadin/text-field": { + "version": "25.0.12", + "resolved": "https://registry.npmjs.org/@vaadin/text-field/-/text-field-25.0.12.tgz", + "integrity": "sha512-qkwA9WpPkDyhVjO/P/hHX+5O6jk16wEgPjxL9BJJsoRO8Eotr1b+u8N9n0G8DSgjJCeT4yxuGhWSQSWoKVSn/g==", + "license": "Apache-2.0", + "dependencies": { + "@open-wc/dedupe-mixin": "^1.3.0", + "@vaadin/a11y-base": "~25.0.12", + "@vaadin/component-base": "~25.0.12", + "@vaadin/field-base": "~25.0.12", + "@vaadin/input-container": "~25.0.12", + "@vaadin/vaadin-themable-mixin": "~25.0.12", + "lit": "^3.0.0" + } + }, + "node_modules/@vaadin/vaadin-development-mode-detector": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@vaadin/vaadin-development-mode-detector/-/vaadin-development-mode-detector-2.0.7.tgz", + "integrity": "sha512-9FhVhr0ynSR3X2ao+vaIEttcNU5XfzCbxtmYOV8uIRnUCtNgbvMOIcyGBvntsX9I5kvIP2dV3cFAOG9SILJzEA==", + "license": "Apache-2.0" + }, + "node_modules/@vaadin/vaadin-themable-mixin": { + "version": "25.0.12", + "resolved": "https://registry.npmjs.org/@vaadin/vaadin-themable-mixin/-/vaadin-themable-mixin-25.0.12.tgz", + "integrity": "sha512-yTgRgRR1Q9vpajXHqubyso8Nrxw9QmJekDFx8BHU4KkpuMciiAP0ZvXSmbXTK/SLyT5pQGGyMtkXaEcbIRBgtQ==", + "license": "Apache-2.0", + "dependencies": { + "@open-wc/dedupe-mixin": "^1.3.0", + "@vaadin/component-base": "~25.0.12", + "lit": "^3.0.0" + } + }, + "node_modules/@vaadin/vaadin-usage-statistics": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@vaadin/vaadin-usage-statistics/-/vaadin-usage-statistics-2.1.3.tgz", + "integrity": "sha512-8r4TNknD7OJQADe3VygeofFR7UNAXZ2/jjBFP5dgI8+2uMfnuGYgbuHivasKr9WSQ64sPej6m8rDoM1uSllXjQ==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@vaadin/vaadin-development-mode-detector": "^2.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, "node_modules/@vitejs/plugin-react": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.1.tgz", @@ -1617,6 +2275,17 @@ } } }, + "node_modules/@zip.js/zip.js": { + "version": "2.8.26", + "resolved": "https://registry.npmjs.org/@zip.js/zip.js/-/zip.js-2.8.26.tgz", + "integrity": "sha512-RQ4h9F6DOiHxpdocUDrOl6xBM+yOtz+LkUol47AVWcfebGBDpZ7w7Xvz9PS24JgXvLGiXXzSAfdCdVy1tPlaFA==", + "license": "BSD-3-Clause", + "engines": { + "bun": ">=0.7.0", + "deno": ">=1.0.0", + "node": ">=18.0.0" + } + }, "node_modules/acorn": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", @@ -1679,6 +2348,12 @@ "node": "18 || 20 || >=22" } }, + "node_modules/base64-js": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.3.1.tgz", + "integrity": "sha512-mLQ4i2QO1ytvGWFWmcngKO//JXAQueZvwEKtjgQFM4jIK0kU+ytMfplL8j+n5mspOfjHwoAg+9yhb7BwAHm36g==", + "license": "MIT" + }, "node_modules/baseline-browser-mapping": { "version": "2.10.29", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.29.tgz", @@ -1705,6 +2380,24 @@ "node": "18 || 20 || >=22" } }, + "node_modules/brotli": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/brotli/-/brotli-1.3.3.tgz", + "integrity": "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==", + "license": "MIT", + "dependencies": { + "base64-js": "^1.1.2" + } + }, + "node_modules/browserify-zlib": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/browserify-zlib/-/browserify-zlib-0.2.0.tgz", + "integrity": "sha512-Z942RysHXmJrhqk88FmKBVq/v5tqmSkDz7p54G/MGyjMnCFFnC79XWNbg+Vta8W6Wb2qtSZTSxIGkJrRpCFEiA==", + "license": "MIT", + "dependencies": { + "pako": "~1.0.5" + } + }, "node_modules/browserslist": { "version": "4.28.2", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", @@ -1739,6 +2432,53 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/call-bind": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", + "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "get-intrinsic": "^1.3.0", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/caniuse-lite": { "version": "1.0.30001792", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001792.tgz", @@ -1760,6 +2500,15 @@ ], "license": "CC-BY-4.0" }, + "node_modules/clone": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", + "integrity": "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==", + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, "node_modules/clsx": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", @@ -1769,6 +2518,70 @@ "node": ">=6" } }, + "node_modules/color": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/color/-/color-5.0.3.tgz", + "integrity": "sha512-ezmVcLR3xAVp8kYOm4GS45ZLLgIE6SPAFoduLr6hTDajwb3KZ2F46gulK3XpcwRFb5KKGCSezCBAY4Dw4HsyXA==", + "license": "MIT", + "dependencies": { + "color-convert": "^3.1.3", + "color-string": "^2.1.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/color-convert": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-3.1.3.tgz", + "integrity": "sha512-fasDH2ont2GqF5HpyO4w0+BcewlhHEZOFn9c1ckZdHpJ56Qb7MHhH/IcJZbBGgvdtwdwNbLvxiBEdg336iA9Sg==", + "license": "MIT", + "dependencies": { + "color-name": "^2.0.0" + }, + "engines": { + "node": ">=14.6" + } + }, + "node_modules/color-name": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-2.1.0.tgz", + "integrity": "sha512-1bPaDNFm0axzE4MEAzKPuqKWeRaT43U/hyxKPBdqTfmPF+d6n7FSoTFxLVULUJOmiLp01KjhIPPH+HrXZJN4Rg==", + "license": "MIT", + "engines": { + "node": ">=12.20" + } + }, + "node_modules/color-string": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-2.1.4.tgz", + "integrity": "sha512-Bb6Cq8oq0IjDOe8wJmi4JeNn763Xs9cfrBcaylK1tPypWzyoy2G3l90v9k64kjphl/ZJjPIShFztenRomi8WTg==", + "license": "MIT", + "dependencies": { + "color-name": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/commander": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", + "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/composed-offset-position": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/composed-offset-position/-/composed-offset-position-0.0.6.tgz", + "integrity": "sha512-Q7dLompI6lUwd7LWyIcP66r4WcS9u7AL2h8HaeipiRfCRPLMWqRx8fYsjb4OHi6UQFifO7XtNC2IlEJ1ozIFxw==", + "license": "MIT", + "peerDependencies": { + "@floating-ui/utils": "^0.2.5" + } + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -1804,13 +2617,535 @@ "node": ">= 8" } }, + "node_modules/crypto-js": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz", + "integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==", + "license": "MIT" + }, + "node_modules/cssfilter": { + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/cssfilter/-/cssfilter-0.0.10.tgz", + "integrity": "sha512-FAaLDaplstoRsDR8XGYH51znUN0UY7nMc6Z9/fvE8EXGwvJE9hu7W2vHwx1+bd6gCYnln9nLbzxFTrcO9YQDZw==", + "license": "MIT" + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, "license": "MIT" }, + "node_modules/d3": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/d3/-/d3-7.9.0.tgz", + "integrity": "sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==", + "license": "ISC", + "dependencies": { + "d3-array": "3", + "d3-axis": "3", + "d3-brush": "3", + "d3-chord": "3", + "d3-color": "3", + "d3-contour": "4", + "d3-delaunay": "6", + "d3-dispatch": "3", + "d3-drag": "3", + "d3-dsv": "3", + "d3-ease": "3", + "d3-fetch": "3", + "d3-force": "3", + "d3-format": "3", + "d3-geo": "3", + "d3-hierarchy": "3", + "d3-interpolate": "3", + "d3-path": "3", + "d3-polygon": "3", + "d3-quadtree": "3", + "d3-random": "3", + "d3-scale": "4", + "d3-scale-chromatic": "3", + "d3-selection": "3", + "d3-shape": "3", + "d3-time": "3", + "d3-time-format": "4", + "d3-timer": "3", + "d3-transition": "3", + "d3-zoom": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-axis": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-axis/-/d3-axis-3.0.0.tgz", + "integrity": "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-brush": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-brush/-/d3-brush-3.0.0.tgz", + "integrity": "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "3", + "d3-transition": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-chord": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-chord/-/d3-chord-3.0.1.tgz", + "integrity": "sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==", + "license": "ISC", + "dependencies": { + "d3-path": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-contour": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-contour/-/d3-contour-4.0.2.tgz", + "integrity": "sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==", + "license": "ISC", + "dependencies": { + "d3-array": "^3.2.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-delaunay": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==", + "license": "ISC", + "dependencies": { + "delaunator": "5" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dsv": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dsv/-/d3-dsv-3.0.1.tgz", + "integrity": "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==", + "license": "ISC", + "dependencies": { + "commander": "7", + "iconv-lite": "0.6", + "rw": "1" + }, + "bin": { + "csv2json": "bin/dsv2json.js", + "csv2tsv": "bin/dsv2dsv.js", + "dsv2dsv": "bin/dsv2dsv.js", + "dsv2json": "bin/dsv2json.js", + "json2csv": "bin/json2dsv.js", + "json2dsv": "bin/json2dsv.js", + "json2tsv": "bin/json2dsv.js", + "tsv2csv": "bin/dsv2dsv.js", + "tsv2json": "bin/dsv2json.js" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-fetch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-fetch/-/d3-fetch-3.0.1.tgz", + "integrity": "sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==", + "license": "ISC", + "dependencies": { + "d3-dsv": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-force": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz", + "integrity": "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-quadtree": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-geo": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz", + "integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2.5.0 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-hierarchy": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz", + "integrity": "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-polygon": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-polygon/-/d3-polygon-3.0.1.tgz", + "integrity": "sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-quadtree": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz", + "integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-random": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-random/-/d3-random-3.0.1.tgz", + "integrity": "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-sankey": { + "version": "0.12.3", + "resolved": "https://registry.npmjs.org/d3-sankey/-/d3-sankey-0.12.3.tgz", + "integrity": "sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-array": "1 - 2", + "d3-shape": "^1.2.0" + } + }, + "node_modules/d3-sankey/node_modules/d3-array": { + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-2.12.1.tgz", + "integrity": "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==", + "license": "BSD-3-Clause", + "dependencies": { + "internmap": "^1.0.0" + } + }, + "node_modules/d3-sankey/node_modules/d3-path": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-1.0.9.tgz", + "integrity": "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==", + "license": "BSD-3-Clause" + }, + "node_modules/d3-sankey/node_modules/d3-shape": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-1.3.7.tgz", + "integrity": "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-path": "1" + } + }, + "node_modules/d3-sankey/node_modules/internmap": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-1.0.1.tgz", + "integrity": "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==", + "license": "ISC" + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-interpolate": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-voronoi-map": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/d3-voronoi-map/-/d3-voronoi-map-2.1.1.tgz", + "integrity": "sha512-mCXfz/kD9IQxjHaU2IMjkO8fSo4J6oysPR2iL+omDsCy1i1Qn6BQ/e4hEAW8C6ms2kfuHwqtbNom80Hih94YsA==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-dispatch": "2.*", + "d3-polygon": "2.*", + "d3-timer": "2.*", + "d3-weighted-voronoi": "1.*" + } + }, + "node_modules/d3-voronoi-map/node_modules/d3-dispatch": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-2.0.0.tgz", + "integrity": "sha512-S/m2VsXI7gAti2pBoLClFFTMOO1HTtT0j99AuXLoGFKO6deHDdnv6ZGTxSTTUTgO1zVcv82fCOtDjYK4EECmWA==", + "license": "BSD-3-Clause" + }, + "node_modules/d3-voronoi-map/node_modules/d3-polygon": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/d3-polygon/-/d3-polygon-2.0.0.tgz", + "integrity": "sha512-MsexrCK38cTGermELs0cO1d79DcTsQRN7IWMJKczD/2kBjzNXxLUWP33qRF6VDpiLV/4EI4r6Gs0DAWQkE8pSQ==", + "license": "BSD-3-Clause" + }, + "node_modules/d3-voronoi-map/node_modules/d3-timer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-2.0.0.tgz", + "integrity": "sha512-TO4VLh0/420Y/9dO3+f9abDEFYeCUr2WZRlxJvbp4HPTQcSylXNiL6yZa9FIUvV1yRiFufl1bszTCLDqv9PWNA==", + "license": "BSD-3-Clause" + }, + "node_modules/d3-voronoi-treemap": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/d3-voronoi-treemap/-/d3-voronoi-treemap-1.1.2.tgz", + "integrity": "sha512-7odu9HdG/yLPWwzDteJq4yd9Q/NwgQV7IE/u36VQtcCK7k1sZwDqbkHCeMKNTBsq5mQjDwolTsrXcU0j8ZEMCA==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-voronoi-map": "2.*" + } + }, + "node_modules/d3-weighted-voronoi": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/d3-weighted-voronoi/-/d3-weighted-voronoi-1.1.3.tgz", + "integrity": "sha512-C3WdvSKl9aqhAy+f3QT3PPsQG6V+ajDfYO3BSclQDSD+araW2xDBFIH67aKzsSuuuKaX8K2y2dGq1fq/dWTVig==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-array": "2", + "d3-polygon": "2" + } + }, + "node_modules/d3-weighted-voronoi/node_modules/d3-array": { + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-2.12.1.tgz", + "integrity": "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==", + "license": "BSD-3-Clause", + "dependencies": { + "internmap": "^1.0.0" + } + }, + "node_modules/d3-weighted-voronoi/node_modules/d3-polygon": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/d3-polygon/-/d3-polygon-2.0.0.tgz", + "integrity": "sha512-MsexrCK38cTGermELs0cO1d79DcTsQRN7IWMJKczD/2kBjzNXxLUWP33qRF6VDpiLV/4EI4r6Gs0DAWQkE8pSQ==", + "license": "BSD-3-Clause" + }, + "node_modules/d3-weighted-voronoi/node_modules/internmap": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-1.0.1.tgz", + "integrity": "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==", + "license": "ISC" + }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -1829,6 +3164,26 @@ } } }, + "node_modules/deep-equal": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-1.1.2.tgz", + "integrity": "sha512-5tdhKF6DbU7iIzrIOa1AOUt39ZRm13cmL1cGEh//aqR8x9+tNfbywRf0n5FD/18OKMdo7DNEtrX2t22ZAkI+eg==", + "license": "MIT", + "dependencies": { + "is-arguments": "^1.1.1", + "is-date-object": "^1.0.5", + "is-regex": "^1.1.4", + "object-is": "^1.1.5", + "object-keys": "^1.1.1", + "regexp.prototype.flags": "^1.5.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -1836,6 +3191,49 @@ "dev": true, "license": "MIT" }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/delaunator": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.1.0.tgz", + "integrity": "sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==", + "license": "ISC", + "dependencies": { + "robust-predicates": "^3.0.2" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -1845,6 +3243,26 @@ "node": ">=8" } }, + "node_modules/dfa": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/dfa/-/dfa-1.2.0.tgz", + "integrity": "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==", + "license": "MIT" + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/electron-to-chromium": { "version": "1.5.353", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.353.tgz", @@ -1865,6 +3283,46 @@ "node": ">=10.13.0" } }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-toolkit": { + "version": "1.46.1", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.46.1.tgz", + "integrity": "sha512-5eNtXOs3tbfxXOj04tjjseeWkRWaoCjdEI+96DgwzZoe6c9juL49pXlzAFTI72aWC9Y8p7168g6XIKjh7k6pyQ==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks" + ] + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -2152,6 +3610,12 @@ "node": ">=16" } }, + "node_modules/flatpickr": { + "version": "4.6.13", + "resolved": "https://registry.npmjs.org/flatpickr/-/flatpickr-4.6.13.tgz", + "integrity": "sha512-97PMG/aywoYpB4IvbvUJi0RQi8vearvU0oov1WW3k0WZPBMrTQVqekSX5CjSG/M4Q3i6A/0FKXC7RyAoAUUSPw==", + "license": "MIT" + }, "node_modules/flatted": { "version": "3.4.2", "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", @@ -2159,6 +3623,15 @@ "dev": true, "license": "ISC" }, + "node_modules/focus-trap": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/focus-trap/-/focus-trap-7.8.0.tgz", + "integrity": "sha512-/yNdlIkpWbM0ptxno3ONTuf+2g318kh2ez3KSeZN5dZ8YC6AAmgeWz+GasYYiBJPFaYcSAPeu4GfhUaChzIJXA==", + "license": "MIT", + "dependencies": { + "tabbable": "^6.4.0" + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -2173,6 +3646,24 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -2183,6 +3674,43 @@ "node": ">=6.9.0" } }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -2209,12 +3737,75 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "license": "ISC" }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/hermes-estree": { "version": "0.25.1", "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", @@ -2232,6 +3823,18 @@ "hermes-estree": "0.25.1" } }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -2252,6 +3855,56 @@ "node": ">=0.8.19" } }, + "node_modules/interactjs": { + "version": "1.10.27", + "resolved": "https://registry.npmjs.org/interactjs/-/interactjs-1.10.27.tgz", + "integrity": "sha512-y/8RcCftGAF24gSp76X2JS3XpHiUvDQyhF8i7ujemBz77hwiHDuJzftHx7thY8cxGogwGiPJ+o97kWB6eAXnsA==", + "license": "MIT", + "dependencies": { + "@interactjs/types": "1.10.27" + } + }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/is-arguments": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz", + "integrity": "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-date-object": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", + "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -2275,6 +3928,24 @@ "node": ">=0.10.0" } }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -2291,6 +3962,13 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/jpeg-exif": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/jpeg-exif/-/jpeg-exif-1.1.4.tgz", + "integrity": "sha512-a+bKEcCjtuW5WTdgeXFzswSrdqi0jk4XlEtZlx5A94wCoBpFjfFTbo/Tra5SpNCl/YFZPvcV1dJc+TAYeg6ROQ==", + "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", + "license": "MIT" + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -2355,6 +4033,17 @@ "json-buffer": "3.0.1" } }, + "node_modules/leaflet": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz", + "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==", + "license": "BSD-2-Clause" + }, + "node_modules/leaflet.heat": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/leaflet.heat/-/leaflet.heat-0.2.0.tgz", + "integrity": "sha512-Cd5PbAA/rX3X3XKxfDoUGi9qp78FyhWYurFg3nsfhntcM/MCNK08pRkf4iEenO1KNqwVPKCmkyktjW3UD+h9bQ==" + }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -2630,6 +4319,37 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/lit": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/lit/-/lit-3.3.2.tgz", + "integrity": "sha512-NF9zbsP79l4ao2SNrH3NkfmFgN/hBYSQo90saIVI1o5GpjAdCPVstVzO1MrLOakHoEhYkrtRjPK6Ob521aoYWQ==", + "license": "BSD-3-Clause", + "dependencies": { + "@lit/reactive-element": "^2.1.0", + "lit-element": "^4.2.0", + "lit-html": "^3.3.0" + } + }, + "node_modules/lit-element": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/lit-element/-/lit-element-4.2.2.tgz", + "integrity": "sha512-aFKhNToWxoyhkNDmWZwEva2SlQia+jfG0fjIWV//YeTaWrVnOxD89dPKfigCUspXFmjzOEUQpOkejH5Ly6sG0w==", + "license": "BSD-3-Clause", + "dependencies": { + "@lit-labs/ssr-dom-shim": "^1.5.0", + "@lit/reactive-element": "^2.1.0", + "lit-html": "^3.3.0" + } + }, + "node_modules/lit-html": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/lit-html/-/lit-html-3.3.2.tgz", + "integrity": "sha512-Qy9hU88zcmaxBXcc10ZpdK7cOLXvXpRoBxERdtqV9QOrfpMZZ6pSYP91LhpPtap3sFMUiL7Tw2RImbe0Al2/kw==", + "license": "BSD-3-Clause", + "dependencies": { + "@types/trusted-types": "^2.0.2" + } + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -2656,6 +4376,15 @@ "yallist": "^3.0.2" } }, + "node_modules/luxon": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz", + "integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -2665,6 +4394,33 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/marked": { + "version": "17.0.6", + "resolved": "https://registry.npmjs.org/marked/-/marked-17.0.6.tgz", + "integrity": "sha512-gB0gkNafnonOw0obSTEGZTT86IuhILt2Wfx0mWH/1Au83kybTayroZ/V6nS25mN7u8ASy+5fMhgB3XPNrOZdmA==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/markerjs2": { + "version": "2.32.7", + "resolved": "https://registry.npmjs.org/markerjs2/-/markerjs2-2.32.7.tgz", + "integrity": "sha512-HeFRZjmc43DOG3lSQp92z49cq2oCYpYn2pX++SkJAW1Dij4xJtRquVRf+cXeSZQWDX3ufns1Ry/bGk+zveP7rA==", + "license": "SEE LICENSE IN LICENSE" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/minimatch": { "version": "10.2.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", @@ -2720,6 +4476,31 @@ "dev": true, "license": "MIT" }, + "node_modules/object-is": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.6.tgz", + "integrity": "sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -2770,6 +4551,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -2790,6 +4577,37 @@ "node": ">=8" } }, + "node_modules/pdfmake": { + "version": "0.2.23", + "resolved": "https://registry.npmjs.org/pdfmake/-/pdfmake-0.2.23.tgz", + "integrity": "sha512-A/IksoKb/ikOZH1edSDJ/2zBbqJKDghD4+fXn3rT7quvCJDlsZMs3NmIB3eajLMMFU9Bd3bZPVvlUMXhvFI+bQ==", + "license": "MIT", + "dependencies": { + "@foliojs-fork/linebreak": "^1.1.2", + "@foliojs-fork/pdfkit": "^0.15.3", + "iconv-lite": "^0.7.1", + "xmldoc": "^2.0.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/pdfmake/node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -2808,6 +4626,23 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/png-js": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/png-js/-/png-js-1.1.0.tgz", + "integrity": "sha512-PM/uYGzGdNSzqeOgly68+6wKQDL1SY0a/N+OEa/+br6LnHWOAJB0Npiamnodfq3jd2LS/i2fMeOKSAILjA+m5Q==", + "dependencies": { + "browserify-zlib": "^0.2.0" + } + }, + "node_modules/polylabel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/polylabel/-/polylabel-1.1.0.tgz", + "integrity": "sha512-bxaGcA40sL3d6M4hH72Z4NdLqxpXRsCFk8AITYg6x1rn1Ei3izf00UMLklerBZTO49aPA3CYrIwVulx2Bce2pA==", + "license": "ISC", + "dependencies": { + "tinyqueue": "^2.0.3" + } + }, "node_modules/postcss": { "version": "8.5.14", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", @@ -2898,6 +4733,20 @@ "react": "^19.2.6" } }, + "node_modules/react-leaflet": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/react-leaflet/-/react-leaflet-5.0.0.tgz", + "integrity": "sha512-CWbTpr5vcHw5bt9i4zSlPEVQdTVcML390TjeDG0cK59z1ylexpqC6M1PJFjV8jD7CF+ACBFsLIDs6DRMoLEofw==", + "license": "Hippocratic-2.1", + "dependencies": { + "@react-leaflet/core": "^3.0.0" + }, + "peerDependencies": { + "leaflet": "^1.9.0", + "react": "^19.0.0", + "react-dom": "^19.0.0" + } + }, "node_modules/react-router": { "version": "7.15.0", "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.15.0.tgz", @@ -2937,6 +4786,32 @@ "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", + "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/robust-predicates": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.3.tgz", + "integrity": "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==", + "license": "Unlicense" + }, "node_modules/rolldown": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0.tgz", @@ -2976,12 +4851,39 @@ "integrity": "sha512-aKs/3GSWyV0mrhNmt/96/Z3yczC3yvrzYATCiCXQebBsGyYzjNdUphRVLeJQ67ySKVXRfMxt2lm12pmXvbPFQQ==", "license": "MIT" }, + "node_modules/rw": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz", + "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==", + "license": "BSD-3-Clause" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/sax": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz", + "integrity": "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=11.0.0" + } + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", "license": "MIT" }, + "node_modules/seedrandom": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/seedrandom/-/seedrandom-3.0.5.tgz", + "integrity": "sha512-8OwmbklUNzwezjGInmZ+2clQmExQPvomqjL7LFqOYqtmuxRgQYqOD3mHaU+MvZn5FLUeVxVfQjwLZW/n/JFuqg==", + "license": "MIT" + }, "node_modules/semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", @@ -2998,6 +4900,38 @@ "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", "license": "MIT" }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-function-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -3021,6 +4955,12 @@ "node": ">=8" } }, + "node_modules/sortablejs": { + "version": "1.15.7", + "resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.15.7.tgz", + "integrity": "sha512-Kk8wLQPlS+yi1ZEf48a4+fzHa4yxjC30M/Sr2AnQu+f/MPwvvX9XjZ6OWejiz8crBsLwSq8GHqaxaET7u6ux0A==", + "license": "MIT" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -3030,6 +4970,12 @@ "node": ">=0.10.0" } }, + "node_modules/svg-arc-to-cubic-bezier": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/svg-arc-to-cubic-bezier/-/svg-arc-to-cubic-bezier-3.2.0.tgz", + "integrity": "sha512-djbJ/vZKZO+gPoSDThGNpKDO+o+bAeA4XQKovvkNCqnIS2t+S4qnLAGQhyyrulhCFRl1WWzAp0wUDV8PpTVU3g==", + "license": "ISC" + }, "node_modules/tabbable": { "version": "6.4.0", "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.4.0.tgz", @@ -3055,6 +5001,21 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/timezone-groups": { + "version": "0.10.4", + "resolved": "https://registry.npmjs.org/timezone-groups/-/timezone-groups-0.10.4.tgz", + "integrity": "sha512-AnkJYrbb7uPkDCEqGeVJiawZNiwVlSkkeX4jZg1gTEguClhyX+/Ezn07KB6DT29tG3UN418ldmS/W6KqGOTDjg==", + "license": "MIT", + "engines": { + "node": ">=18.12.0" + } + }, + "node_modules/tiny-inflate": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz", + "integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==", + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.16", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", @@ -3071,6 +5032,12 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinyqueue": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-2.0.3.tgz", + "integrity": "sha512-ppJZNDuKGgxzkHihX8v9v9G5f+18gzaTfrukGrq6ueg0lmH4nqVnA2IPG0AEH3jKEk2GRJCUhDoqpoiw3PHLBA==", + "license": "ISC" + }, "node_modules/ts-api-utils": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", @@ -3103,6 +5070,18 @@ "node": ">= 0.8.0" } }, + "node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/typescript": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", @@ -3148,6 +5127,32 @@ "devOptional": true, "license": "MIT" }, + "node_modules/unicode-properties": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/unicode-properties/-/unicode-properties-1.4.1.tgz", + "integrity": "sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==", + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.0", + "unicode-trie": "^2.0.0" + } + }, + "node_modules/unicode-trie": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unicode-trie/-/unicode-trie-2.0.0.tgz", + "integrity": "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==", + "license": "MIT", + "dependencies": { + "pako": "^0.2.5", + "tiny-inflate": "^1.0.0" + } + }, + "node_modules/unicode-trie/node_modules/pako": { + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz", + "integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==", + "license": "MIT" + }, "node_modules/update-browserslist-db": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", @@ -3301,6 +5306,40 @@ "node": ">=0.10.0" } }, + "node_modules/xmldoc": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/xmldoc/-/xmldoc-2.0.3.tgz", + "integrity": "sha512-6gRk4NY/Jvg67xn7OzJuxLRsGgiXBaPUQplVJ/9l99uIugxh4FTOewYz5ic8WScj7Xx/2WvhENiQKwkK9RpE4w==", + "license": "MIT", + "dependencies": { + "sax": "^1.4.3" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/xss": { + "version": "1.0.13", + "resolved": "https://registry.npmjs.org/xss/-/xss-1.0.13.tgz", + "integrity": "sha512-clu7dxTm1e8Mo5fz3n/oW3UCXBfV89xZ72jM8yzo1vR/pIS0w3sgB3XV2H8Vm6zfGnHL0FzvLJPJEBhd86/z4Q==", + "license": "MIT", + "dependencies": { + "commander": "^2.20.3", + "cssfilter": "0.0.10" + }, + "bin": { + "xss": "bin/xss" + }, + "engines": { + "node": ">= 0.10.0" + } + }, + "node_modules/xss/node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "license": "MIT" + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 1773b11..dfe403f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,16 +10,21 @@ "preview": "vite preview" }, "dependencies": { + "@arcgis/core": "^5.0.19", "@headlessui/react": "^2.2.10", "@heroicons/react": "^2.2.0", "@tailwindcss/vite": "^4.3.0", + "leaflet": "^1.9.4", + "leaflet.heat": "^0.2.0", "react": "^19.2.6", "react-dom": "^19.2.6", + "react-leaflet": "^5.0.0", "react-router": "^7.15.0", "tailwindcss": "^4.3.0" }, "devDependencies": { "@eslint/js": "^10.0.1", + "@types/leaflet": "^1.9.21", "@types/node": "^24.12.3", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 153385a..a83d4e4 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,30 +1,158 @@ -// frontend\src\App.tsx +// frontend/src/App.tsx -import { useEffect, useState, type FormEvent } from 'react' +import { useEffect, useState } from 'react' import { Navigate, Route, Routes } from 'react-router' -import LoginForm from './components/LoginForm' +import LoginPage from './pages/LoginPage' import AppLayout from './components/AppLayout' import DashboardPage from './pages/DashboardPage' -import DevicesPage from './pages/DevicesPage' -import IncidentsPage from './pages/IncidentsPage' +import DevicesPage from './pages/devices/DevicesPage' +import OperationsPage from './pages/operations/OperationsPage' import CalendarPage from './pages/CalendarPage' import DocumentsPage from './pages/DocumentsPage' import ReportsPage from './pages/ReportsPage' -import SettingsPage from './pages/SettingsPage' -import type { Team, User } from './components/types' +import SettingsPage from './pages/settings/SettingsPage' import './App.css' +import LoadingSpinner from './components/LoadingSpinner' +import NotificationCenter from './components/NotificationCenter' +import type { Notification, User } from './components/types' +import AdministrationPage from './pages/administration/AdministrationPage' +import { hasRight } from './components/permissions' const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080' +function parseNotificationEvent(event: MessageEvent) { + try { + return JSON.parse(event.data) as Notification + } catch { + return null + } +} + +function RequireRight({ + user, + right, + children, +}: { + user: User + right: string + children: React.ReactNode +}) { + if (!hasRight(user, right)) { + return + } + + return children +} + function App() { const [user, setUser] = useState(null) const [isLoading, setIsLoading] = useState(true) - const [isLoggingIn, setIsLoggingIn] = useState(false) + + const [notifications, setNotifications] = useState([]) + const [unreadJournalOperationIds, setUnreadJournalOperationIds] = useState>( + () => new Set(), + ) + + const unreadNotificationCount = notifications.filter( + (notification) => !notification.readAt, + ).length useEffect(() => { - loadUser() + void loadUser() }, []) + useEffect(() => { + if (!user) { + setNotifications([]) + setUnreadJournalOperationIds(new Set()) + return + } + + void loadNotifications() + void loadUnreadJournalOperations() + + const eventSource = new EventSource(`${API_URL}/events`, { + withCredentials: true, + }) + + eventSource.addEventListener('notification', (event) => { + const notification = parseNotificationEvent(event) + + if (!notification) { + return + } + + if (notification.visible !== false) { + setNotifications((currentNotifications) => [ + notification, + ...currentNotifications.filter((item) => item.id !== notification.id), + ]) + } + + if (notification.entityType === 'operation') { + window.dispatchEvent( + new CustomEvent('operation-notification', { + detail: notification, + }), + ) + } + + if (notification.entityType === 'device') { + window.dispatchEvent( + new CustomEvent('device-notification', { + detail: notification, + }), + ) + } + }) + + eventSource.addEventListener('operation-event', (event) => { + const notification = parseNotificationEvent(event) + + if (!notification) { + return + } + + if ( + notification.type === 'operation.journal' && + notification.entityType === 'operation' && + notification.entityId + ) { + setUnreadJournalOperationIds((currentIds) => { + const nextIds = new Set(currentIds) + nextIds.add(notification.entityId) + return nextIds + }) + } + + if (notification.entityType === 'operation') { + window.dispatchEvent( + new CustomEvent('operation-notification', { + detail: notification, + }), + ) + } + }) + + eventSource.addEventListener('device-event', (event) => { + const notification = parseNotificationEvent(event) + + if (!notification) { + return + } + + window.dispatchEvent( + new CustomEvent('device-notification', { + detail: notification, + }), + ) + }) + + return () => { + eventSource.close() + } + }, [user]) + async function loadUser() { try { const response = await fetch(`${API_URL}/auth/me`, { @@ -45,41 +173,87 @@ function App() { } } - async function handleLogin(event: FormEvent) { - event.preventDefault() - - setIsLoggingIn(true) - - const formData = new FormData(event.currentTarget) - - const identifier = String(formData.get('identifier')) - const password = String(formData.get('password')) - + async function loadNotifications() { try { - const response = await fetch(`${API_URL}/auth/login`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, + const response = await fetch(`${API_URL}/notifications`, { credentials: 'include', - body: JSON.stringify({ identifier, password }), }) if (!response.ok) { - const errorData = await response.json().catch(() => null) - alert(errorData?.error ?? 'Login fehlgeschlagen') return } const data = await response.json() - setUser(data.user) + setNotifications(data.notifications ?? []) } catch { - alert('Backend ist nicht erreichbar') - } finally { - setIsLoggingIn(false) + // bewusst still } } + async function loadUnreadJournalOperations() { + try { + const response = await fetch(`${API_URL}/operations/journal-unread`, { + credentials: 'include', + }) + + if (!response.ok) { + return + } + + const data = await response.json() + setUnreadJournalOperationIds(new Set(data.operationIds ?? [])) + } catch { + // bewusst still + } + } + + async function markOperationJournalRead(operationId: string) { + setUnreadJournalOperationIds((currentIds) => { + const nextIds = new Set(currentIds) + nextIds.delete(operationId) + return nextIds + }) + + await fetch(`${API_URL}/operations/${operationId}/journal/read`, { + method: 'POST', + credentials: 'include', + }).catch(() => undefined) + } + + async function markNotificationRead(notificationId: string) { + setNotifications((currentNotifications) => + currentNotifications.map((notification) => + notification.id === notificationId + ? { + ...notification, + readAt: notification.readAt ?? new Date().toISOString(), + } + : notification, + ), + ) + + await fetch(`${API_URL}/notifications/${notificationId}/read`, { + method: 'POST', + credentials: 'include', + }).catch(() => undefined) + } + + async function markAllNotificationsRead() { + const now = new Date().toISOString() + + setNotifications((currentNotifications) => + currentNotifications.map((notification) => ({ + ...notification, + readAt: notification.readAt ?? now, + })), + ) + + await fetch(`${API_URL}/notifications/read-all`, { + method: 'POST', + credentials: 'include', + }).catch(() => undefined) + } + async function handleLogout() { try { await fetch(`${API_URL}/auth/logout`, { @@ -88,39 +262,119 @@ function App() { }) } finally { setUser(null) + setNotifications([]) + setUnreadJournalOperationIds(new Set()) } } if (isLoading) { return (
-

- Loading... -

+
) } if (!user) { return ( - ) } return ( - + + } + > } /> - } /> - } /> - } /> - } /> - } /> - } /> + + + + + } + /> + + + + + } + /> + + + + + } + /> + + + + + } + /> + + + + + } + /> + + + + + } + /> + + + + + } + /> + } /> diff --git a/frontend/src/components/AddressCombobox.tsx b/frontend/src/components/AddressCombobox.tsx new file mode 100644 index 0000000..fb3849c --- /dev/null +++ b/frontend/src/components/AddressCombobox.tsx @@ -0,0 +1,251 @@ +// frontend\src\components\AddressCombobox.tsx + +import { + Combobox as HeadlessCombobox, + ComboboxButton, + ComboboxInput, + ComboboxOption, + ComboboxOptions, + Label, +} from '@headlessui/react' +import { ChevronDownIcon } from '@heroicons/react/20/solid' +import { useEffect, useMemo, useRef, useState } from 'react' + +const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080' + +export type AddressSuggestion = { + id: string + label: string + lat: number + lon: number + type?: string +} + +type AddressComboboxProps = { + id: string + name: string + label: string + value: string + onChange: (value: string) => void + placeholder?: string +} + +const inputClassName = + 'block w-full rounded-md bg-white py-1.5 pr-12 pl-3 text-base text-gray-900 outline-1 -outline-offset-1 outline-gray-300 placeholder:text-gray-400 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 sm:text-sm/6 dark:bg-white/5 dark:text-white dark:outline-white/10 dark:placeholder:text-gray-500 dark:focus:outline-indigo-500' + +function classNames(...classes: Array) { + return classes.filter(Boolean).join(' ') +} + +function buildOpenStreetMapEmbedUrl(address: AddressSuggestion) { + const offset = 0.004 + + const params = new URLSearchParams({ + bbox: [ + address.lon - offset, + address.lat - offset, + address.lon + offset, + address.lat + offset, + ].join(','), + layer: 'mapnik', + marker: `${address.lat},${address.lon}`, + }) + + return `https://www.openstreetmap.org/export/embed.html?${params.toString()}` +} + +export default function AddressCombobox({ + id, + name, + label, + value, + onChange, + placeholder = 'Adresse suchen', +}: AddressComboboxProps) { + const [query, setQuery] = useState(value) + const [suggestions, setSuggestions] = useState([]) + const [selectedSuggestion, setSelectedSuggestion] = useState(null) + const [previewSuggestion, setPreviewSuggestion] = useState(null) + const [isSearching, setIsSearching] = useState(false) + const [searchError, setSearchError] = useState(null) + + const previousValueRef = useRef(value) + + useEffect(() => { + if (value !== previousValueRef.current && value !== query) { + setQuery(value) + setSelectedSuggestion(null) + setPreviewSuggestion(null) + } + + previousValueRef.current = value + }, [value, query]) + + useEffect(() => { + const trimmedQuery = query.trim() + + if (trimmedQuery.length < 3) { + setSuggestions([]) + setPreviewSuggestion(null) + setSearchError(null) + setIsSearching(false) + return + } + + const abortController = new AbortController() + + const timeout = window.setTimeout(async () => { + setIsSearching(true) + setSearchError(null) + + try { + const response = await fetch( + `${API_URL}/address-search?q=${encodeURIComponent(trimmedQuery)}`, + { + credentials: 'include', + signal: abortController.signal, + }, + ) + + if (!response.ok) { + const errorData = await response.json().catch(() => null) + throw new Error(errorData?.error ?? 'Adresssuche konnte nicht geladen werden') + } + + const data = await response.json() + const nextSuggestions = (data.suggestions ?? []) as AddressSuggestion[] + + setSuggestions(nextSuggestions) + setPreviewSuggestion((currentPreview) => currentPreview ?? nextSuggestions[0] ?? null) + } catch (error) { + if (error instanceof DOMException && error.name === 'AbortError') { + return + } + + setSuggestions([]) + setPreviewSuggestion(null) + setSearchError( + error instanceof Error + ? error.message + : 'Adresssuche konnte nicht geladen werden', + ) + } finally { + setIsSearching(false) + } + }, 600) + + return () => { + window.clearTimeout(timeout) + abortController.abort() + } + }, [query]) + + const mapUrl = useMemo(() => { + if (!previewSuggestion) { + return null + } + + return buildOpenStreetMapEmbedUrl(previewSuggestion) + }, [previewSuggestion]) + + function handleInputChange(nextValue: string) { + setQuery(nextValue) + setSelectedSuggestion(null) + setPreviewSuggestion(null) + onChange(nextValue) + } + + function handleSelect(suggestion: AddressSuggestion | null) { + setSelectedSuggestion(suggestion) + + if (!suggestion) { + return + } + + setQuery(suggestion.label) + setPreviewSuggestion(suggestion) + onChange(suggestion.label) + } + + return ( +
+ + + + + +
+ suggestion?.label ?? value} + onChange={(event) => handleInputChange(event.target.value)} + /> + + + + + {(suggestions.length > 0 || isSearching || searchError) && ( + + {isSearching && ( +
+ Adressen werden gesucht... +
+ )} + + {searchError && ( +
+ {searchError} +
+ )} + + {suggestions.map((suggestion) => ( + + {suggestion.label} + + {suggestion.type && ( + + {suggestion.type} + + )} + + ))} +
+ )} +
+
+ + {mapUrl && previewSuggestion && ( +
+