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)
200 lines
6.0 KiB
Go
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),
|
|
}
|
|
}
|