Files
paliad/internal/handlers/builder_search.go
mAi 3e93e94d10
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
feat(builder): B3 — event-triggered mode + universal search (m/paliad#153)
PRD §2.2 + §3.1: the page-header search box drives a typed dropdown
returning grouped event / scenario / project hits, and the "Ereignis"
entry mode is enabled. Picking an event creates a scratch scenario
with one triplet anchored on that event's proceeding type, with the
event card auto-anchored (lime band + "━━━━ DU BIST HIER ━━━━" divider
above the next-coming events).

Backend: new GET /api/builder/search reuses
DeadlineSearchService.SearchEvents for the events corpus (UPC v1),
filters owned scenarios by ILIKE on name, and reuses ProjectService.List
for the Akten group (team-RLS via visibilityPredicate). Each group is
capped independently (default 8 events / 5 scenarios / 5 projects, max
30). Missing services degrade gracefully — empty group, not 503.

Frontend: builder-search.ts owns the dropdown (debounced 180ms,
arrow-key navigation, Enter to pick, abort on next query). builder.ts
gains mode state ("cold" | "event" | "akte"), wires the mode bar +
search input, and runs applyAnchorHighlight after triplet hydration —
the helper finds the .fr-col-item with the picked rule_id, adds the
.builder-anchor-card lime band, and inserts a full-width
.builder-anchor-divider after the anchor's row in the columns grid
via JS row-index math (the grid is row-major with 3 header cells
+ 3-cells-per-row body).

Filter pill reset: setMode() clears the search input and closes the
dropdown when switching entry modes. Forum/proc/party/kind chips are
not yet rendered separately (they live in the search dropdown today);
the reset hook attaches there too when those land in a follow-up.

Verification:
  - bun build (frontend bundles + i18n scan clean)
  - go vet ./... + go test ./... (all packages pass)
  - Playwright: mode switch focuses search, debounced fetch fires,
    typed result groups render with N · M · K pluralization, event
    pick creates scratch scenario + adds proceeding, anchor card
    + DU BIST HIER divider render in the columns grid (screenshots
    confirmed visually)
2026-05-28 10:10:33 +02:00

200 lines
6.0 KiB
Go

package handlers
import (
"net/http"
"strconv"
"strings"
"github.com/google/uuid"
"mgit.msbls.de/m/paliad/internal/services"
)
// t-paliad-346 / m/paliad#153 B3 — universal search for the Litigation
// Builder. Returns events + scenarios + projects (Akten) keyed by type
// so the search dropdown can render typed result groups.
//
// GET /api/builder/search?q=<term>&limit=<n>
//
// Response shape:
//
// {
// "query": "<echoed q>",
// "events": [ EventSearchHit, ... ], // anchor_rule_id + proceeding_type embedded
// "scenarios": [ { id, name, status, updated_at }, ... ],
// "projects": [ { id, title, type, reference, case_number, matter_number, client_number }, ... ],
// "counts": { "events": N, "scenarios": M, "projects": K }
// }
//
// Each group is independently capped (default 8 events / 5 scenarios /
// 5 projects, max 30 per group). Missing services degrade gracefully —
// an unavailable group is returned as an empty array, not an error,
// so a knowledge-only deploy (DATABASE_URL unset) can still serve a
// best-effort empty response shape rather than a 503 wall.
type builderSearchScenarioHit struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
Status string `json:"status"`
UpdatedAt string `json:"updated_at"`
}
type builderSearchProjectHit struct {
ID uuid.UUID `json:"id"`
Type string `json:"type"`
Title string `json:"title"`
Reference *string `json:"reference,omitempty"`
CaseNumber *string `json:"case_number,omitempty"`
MatterNumber *string `json:"matter_number,omitempty"`
ClientNumber *string `json:"client_number,omitempty"`
}
type builderSearchResponse struct {
Query string `json:"query"`
Events []services.EventSearchHit `json:"events"`
Scenarios []builderSearchScenarioHit `json:"scenarios"`
Projects []builderSearchProjectHit `json:"projects"`
Counts builderSearchCounts `json:"counts"`
}
type builderSearchCounts struct {
Events int `json:"events"`
Scenarios int `json:"scenarios"`
Projects int `json:"projects"`
}
// handleBuilderSearch — GET /api/builder/search?q=<term>&limit=<n>
//
// Auth required. Returns 200 with empty groups when q is empty (matches
// the fristenrechner search ergonomic — frontend can boot without a
// pre-flight round trip).
func handleBuilderSearch(w http.ResponseWriter, r *http.Request) {
uid, ok := requireUser(w, r)
if !ok {
return
}
q := strings.TrimSpace(r.URL.Query().Get("q"))
perGroupLimit := parseBuilderSearchLimit(r.URL.Query().Get("limit"))
resp := builderSearchResponse{
Query: q,
Events: []services.EventSearchHit{},
Scenarios: []builderSearchScenarioHit{},
Projects: []builderSearchProjectHit{},
}
if q == "" {
// Match fristenrechner search: empty query → empty groups, not 400.
writeJSON(w, http.StatusOK, resp)
return
}
ctx := r.Context()
// Events: reuse the SearchEvents shape so anchor_rule_id +
// proceeding_type travel with each hit. UPC v1 (PRD §0.4) — the
// jurisdiction filter pins the corpus the builder serves today.
if dbSvc != nil && dbSvc.deadlineSearch != nil {
eventsResp, err := dbSvc.deadlineSearch.SearchEvents(ctx, q, services.EventSearchOptions{
Jurisdiction: "UPC",
Limit: perGroupLimit.events,
})
if err == nil && eventsResp != nil {
resp.Events = eventsResp.Events
}
}
// Scenarios: caller's own scenarios filtered by ILIKE on name.
// Borrows ListMyScenarios + filters in-memory; the list endpoint
// already caps at the small per-user fan-out and there's no index
// on (owner_id, name) yet — in-memory filter is cheap at 10s-of-
// rows scale.
if dbSvc != nil && dbSvc.scenarioBuilder != nil {
scenarios, err := dbSvc.scenarioBuilder.ListMyScenarios(ctx, uid, "active")
if err == nil {
needle := strings.ToLower(q)
hits := []builderSearchScenarioHit{}
for _, sc := range scenarios {
if !strings.Contains(strings.ToLower(sc.Name), needle) {
continue
}
hits = append(hits, builderSearchScenarioHit{
ID: sc.ID,
Name: sc.Name,
Status: sc.Status,
UpdatedAt: sc.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"),
})
if len(hits) >= perGroupLimit.scenarios {
break
}
}
resp.Scenarios = hits
}
}
// Projects (Akten): visible projects filtered by trigram/ILIKE on
// title, reference, client_number, matter_number. ProjectService.List
// already applies team-based RLS via visibilityPredicate.
if dbSvc != nil && dbSvc.projects != nil {
projects, err := dbSvc.projects.List(ctx, uid, services.ProjectFilter{
Search: q,
})
if err == nil {
hits := make([]builderSearchProjectHit, 0, len(projects))
for _, p := range projects {
hits = append(hits, builderSearchProjectHit{
ID: p.ID,
Type: p.Type,
Title: p.Title,
Reference: p.Reference,
CaseNumber: p.CaseNumber,
MatterNumber: p.MatterNumber,
ClientNumber: p.ClientNumber,
})
if len(hits) >= perGroupLimit.projects {
break
}
}
resp.Projects = hits
}
}
resp.Counts = builderSearchCounts{
Events: len(resp.Events),
Scenarios: len(resp.Scenarios),
Projects: len(resp.Projects),
}
writeJSON(w, http.StatusOK, resp)
}
type builderSearchPerGroup struct {
events int
scenarios int
projects int
}
// parseBuilderSearchLimit reads ?limit=<n> as a hint for the events
// group (largest expected hit count). Scenarios + projects use smaller
// caps because their drop-down rows are visually heavier. The shared
// caller-supplied bound is interpreted as the events cap; scenarios
// and projects are derived from it.
func parseBuilderSearchLimit(raw string) builderSearchPerGroup {
def := builderSearchPerGroup{events: 8, scenarios: 5, projects: 5}
if raw == "" {
return def
}
n, err := strconv.Atoi(raw)
if err != nil || n <= 0 {
return def
}
if n > 30 {
n = 30
}
return builderSearchPerGroup{
events: n,
scenarios: max(1, n/2),
projects: max(1, n/2),
}
}