// 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, " · ") }