teg/backend/search.go
2026-05-19 18:07:22 +02:00

402 lines
7.3 KiB
Go

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