// backend\search.go package main import ( "context" "net/http" "strconv" "strings" "time" ) 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"` } type globalSearchOptions struct { Query string Limit int ExactPhrase bool CaseSensitive bool } 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")) options := globalSearchOptions{ Query: query, Limit: parseSearchLimit(r.URL.Query().Get("limit"), 8), ExactPhrase: parseSearchBool(r.URL.Query().Get("exact")), CaseSensitive: parseSearchBool(r.URL.Query().Get("caseSensitive")), } if !strings.Contains(query, " ") { options.ExactPhrase = false } if len([]rune(query)) < 2 { writeJSON(w, http.StatusOK, globalSearchResponse{ Query: query, Groups: []globalSearchGroup{}, }) return } groups := make([]globalSearchGroup, 0, 5) if userCanSearch(user, "operations:read") { items, err := s.searchOperations(r.Context(), options) 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, "operations:read") { items, err := s.searchOperationJournalEntries(r.Context(), options) if err != nil { writeError(w, http.StatusInternalServerError, "Einsatz-Journale konnten nicht durchsucht werden") return } if len(items) > 0 { groups = append(groups, globalSearchGroup{ ID: "operation-journals", Title: "Einsatz-Journale", Items: items, }) } } if userCanSearch(user, "devices:read") { items, err := s.searchDeviceJournalEntries(r.Context(), options) if err != nil { writeError(w, http.StatusInternalServerError, "Geräte-Journale konnten nicht durchsucht werden") return } if len(items) > 0 { groups = append(groups, globalSearchGroup{ ID: "device-journals", Title: "Geräte-Journale", Items: items, }) } } if userCanSearch(user, "devices:read") { items, err := s.searchDevices(r.Context(), options) 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(), options) 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, options globalSearchOptions) ([]globalSearchItem, error) { patterns := buildSearchPatterns(options) rows, err := s.db.Query( ctx, ` SELECT id::TEXT, operation_number, operation_name, offense, kw_address, operation_leader, operation_team FROM operations WHERE ( ( $3 = false AND CONCAT_WS( ' ', operation_number, operation_name, offense, case_worker, target_object, kw_address, operation_leader, operation_team, legend, remark ) ILIKE ALL($1::TEXT[]) ) OR ( $3 = true AND CONCAT_WS( ' ', operation_number, operation_name, offense, case_worker, target_object, kw_address, operation_leader, operation_team, legend, remark ) LIKE ALL($1::TEXT[]) ) ) ORDER BY updated_at DESC LIMIT $2 `, patterns, options.Limit, options.CaseSensitive, ) 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, options globalSearchOptions) ([]globalSearchItem, error) { patterns := buildSearchPatterns(options) rows, err := s.db.Query( ctx, ` SELECT id::TEXT, inventory_number, manufacturer, model, serial_number, mac_address, COALESCE(ip_address, ''), location, loan_status, COALESCE(firmware_version, '') FROM devices WHERE ( ( $3 = false AND CONCAT_WS( ' ', inventory_number, manufacturer, model, serial_number, mac_address, COALESCE(ip_address, ''), location, loan_status, comment, COALESCE(firmware_version, '') ) ILIKE ALL($1::TEXT[]) ) OR ( $3 = true AND CONCAT_WS( ' ', inventory_number, manufacturer, model, serial_number, mac_address, COALESCE(ip_address, ''), location, loan_status, comment, COALESCE(firmware_version, '') ) LIKE ALL($1::TEXT[]) ) ) ORDER BY updated_at DESC LIMIT $2 `, patterns, options.Limit, options.CaseSensitive, ) 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 ipAddress string var location string var loanStatus string var firmwareVersion string if err := rows.Scan( &id, &inventoryNumber, &manufacturer, &model, &serialNumber, &macAddress, &ipAddress, &location, &loanStatus, &firmwareVersion, ); err != nil { return nil, err } items = append(items, globalSearchItem{ ID: "device-" + id, EntityID: id, EntityType: "device", Title: inventoryNumber, Subtitle: joinSearchSubtitle( manufacturer, model, serialNumber, macAddress, ipAddress, location, loanStatus, firmwareVersion, ), Href: "/geraete", Data: map[string]any{ "inventoryNumber": inventoryNumber, }, }) } return items, rows.Err() } func (s *Server) searchOperationJournalEntries(ctx context.Context, options globalSearchOptions) ([]globalSearchItem, error) { patterns := buildSearchPatterns(options) rows, err := s.db.Query( ctx, ` SELECT operation_history.id::TEXT, operation_history.operation_id::TEXT, operations.operation_number, operations.operation_name, COALESCE(users.display_name, users.username, users.email, 'Unbekannt'), COALESCE(journal_change.change->>'newValue', ''), operation_history.created_at FROM operation_history INNER JOIN operations ON operations.id = operation_history.operation_id LEFT JOIN users ON users.id = operation_history.user_id CROSS JOIN LATERAL jsonb_array_elements(operation_history.changes) AS journal_change(change) WHERE journal_change.change->>'field' = 'journal' AND ( ( $3 = false AND journal_change.change->>'newValue' ILIKE ALL($1::TEXT[]) ) OR ( $3 = true AND journal_change.change->>'newValue' LIKE ALL($1::TEXT[]) ) ) ORDER BY operation_history.created_at DESC LIMIT $2 `, patterns, options.Limit, options.CaseSensitive, ) if err != nil { return nil, err } defer rows.Close() items := []globalSearchItem{} for rows.Next() { var id string var operationID string var operationNumber string var operationName string var userDisplayName string var content string var createdAt time.Time if err := rows.Scan( &id, &operationID, &operationNumber, &operationName, &userDisplayName, &content, &createdAt, ); err != nil { return nil, err } title := operationName if strings.TrimSpace(title) == "" { title = operationNumber } items = append(items, globalSearchItem{ ID: "operation-journal-" + id, EntityID: operationID, EntityType: "operationJournal", Title: title, Subtitle: joinSearchSubtitle( formatSearchDate(createdAt), userDisplayName, truncateSearchText(content, 140), ), Href: "/einsaetze", Data: map[string]any{ "historyId": id, "operationId": operationID, "operationNumber": operationNumber, "journalCreatedAt": createdAt, }, }) } return items, rows.Err() } func (s *Server) searchDeviceJournalEntries(ctx context.Context, options globalSearchOptions) ([]globalSearchItem, error) { patterns := buildSearchPatterns(options) rows, err := s.db.Query( ctx, ` SELECT device_history.id::TEXT, device_history.device_id::TEXT, devices.inventory_number, devices.manufacturer, devices.model, COALESCE(users.display_name, users.username, users.email, 'Unbekannt'), COALESCE(journal_change.change->>'newValue', ''), device_history.created_at FROM device_history INNER JOIN devices ON devices.id = device_history.device_id LEFT JOIN users ON users.id = device_history.user_id CROSS JOIN LATERAL jsonb_array_elements(device_history.changes) AS journal_change(change) WHERE journal_change.change->>'field' = 'journal' AND ( ( $3 = false AND journal_change.change->>'newValue' ILIKE ALL($1::TEXT[]) ) OR ( $3 = true AND journal_change.change->>'newValue' LIKE ALL($1::TEXT[]) ) ) ORDER BY device_history.created_at DESC LIMIT $2 `, patterns, options.Limit, options.CaseSensitive, ) if err != nil { return nil, err } defer rows.Close() items := []globalSearchItem{} for rows.Next() { var id string var deviceID string var inventoryNumber string var manufacturer string var model string var userDisplayName string var content string var createdAt time.Time if err := rows.Scan( &id, &deviceID, &inventoryNumber, &manufacturer, &model, &userDisplayName, &content, &createdAt, ); err != nil { return nil, err } deviceName := joinSearchSubtitle(manufacturer, model) items = append(items, globalSearchItem{ ID: "device-journal-" + id, EntityID: deviceID, EntityType: "deviceJournal", Title: inventoryNumber, Subtitle: joinSearchSubtitle( deviceName, formatSearchDate(createdAt), userDisplayName, truncateSearchText(content, 140), ), Href: "/geraete", Data: map[string]any{ "historyId": id, "deviceId": deviceID, "inventoryNumber": inventoryNumber, "journalCreatedAt": createdAt, }, }) } return items, rows.Err() } func (s *Server) searchUsers(ctx context.Context, options globalSearchOptions) ([]globalSearchItem, error) { patterns := buildSearchPatterns(options) rows, err := s.db.Query( ctx, ` SELECT id::TEXT, COALESCE(username, ''), COALESCE(display_name, ''), COALESCE(email, ''), COALESCE(unit, ''), COALESCE(user_group, '') FROM users WHERE ( ( $3 = false AND CONCAT_WS( ' ', username, display_name, email, unit, user_group ) ILIKE ALL($1::TEXT[]) ) OR ( $3 = true AND CONCAT_WS( ' ', username, display_name, email, unit, user_group ) LIKE ALL($1::TEXT[]) ) ) ORDER BY display_name ASC, username ASC, email ASC LIMIT $2 `, patterns, options.Limit, options.CaseSensitive, ) 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 parseSearchBool(value string) bool { value = strings.ToLower(strings.TrimSpace(value)) return value == "1" || value == "true" || value == "yes" || value == "on" } func buildSearchPatterns(options globalSearchOptions) []string { query := strings.TrimSpace(options.Query) if query == "" { return []string{"%%"} } if options.ExactPhrase && strings.Contains(query, " ") { return []string{"%" + query + "%"} } parts := strings.Fields(query) patterns := make([]string, 0, len(parts)) for _, part := range parts { part = strings.TrimSpace(part) if part != "" { patterns = append(patterns, "%"+part+"%") } } if len(patterns) == 0 { return []string{"%" + query + "%"} } return patterns } 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, " · ") } func formatSearchDate(value time.Time) string { if value.IsZero() { return "" } return value.Format("02.01.2006 15:04") } func truncateSearchText(value string, maxLength int) string { value = strings.TrimSpace(value) if maxLength <= 0 { return value } runes := []rune(value) if len(runes) <= maxLength { return value } return strings.TrimSpace(string(runes[:maxLength])) + "…" }