teg/backend/search.go
2026-05-22 14:11:18 +02:00

779 lines
15 KiB
Go

// 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])) + "…"
}