Files
paliad/internal/handlers/search.go
mAi dc5f11ddef feat(projects): add 'other' as a real type; drop synthetic Empty filter
m/paliad#51 (t-paliad-221) — the type chip filter on /projects used to
treat unclassified projects as a synthetic "Empty" bucket. Make 'other'
a first-class projects.type value so every row carries a meaningful
label and the filter UI stops needing a NULL/Empty shim.

- mig 110: extend projects.type CHECK to include 'other'; backfill any
  NULL rows defensively (production query confirmed zero, but the
  NOT NULL constraint isn't load-bearing once the IN-list changes).
- Go: add ProjectTypeOther constant; isValidProjectType + humanProjectType
  recognise it; handler doc lists 'other' in the ?type whitelist.
- Frontend: new chip in the projects.tsx type filter, new option in the
  Create-Project form, DE "Sonstiges" / EN "Other" labels for the
  projects.type and projects.chip.type i18n families.

Also drops a stray data-i18n-text attribute on the existing 'project'
chip checkbox (it had no consumer in i18n.ts and the surrounding markup
was nesting a <span> inside an <input>).
2026-05-20 14:43:42 +02:00

481 lines
13 KiB
Go

package handlers
import (
"context"
"net/http"
"net/url"
"strings"
"github.com/google/uuid"
"mgit.msbls.de/m/paliad/internal/auth"
"mgit.msbls.de/m/paliad/internal/checklists"
"mgit.msbls.de/m/paliad/internal/services"
)
// Global search across every surface of Paliad — projects, deadlines, appointments,
// glossary, courts, checklist templates, link hub, users. The handler mixes
// in-memory scans (static curated content) with visibility-gated SQL for the
// tenant data. Each category is capped at maxPerCategory; overall output at
// maxTotalResults. Ranking is intentionally shallow — the caller types further
// if the list is too long.
const (
maxPerCategory = 5
maxTotalResults = 20
maxQueryRunes = 128
)
// SearchResult is the shape every category returns to the frontend. id is a
// stable identifier (UUID, slug, or term string); type names the category;
// subtitle is optional secondary text; url is where the result navigates.
type SearchResult struct {
ID string `json:"id"`
Type string `json:"type"`
Title string `json:"title"`
Subtitle string `json:"subtitle,omitempty"`
URL string `json:"url"`
}
// searchResponse bundles the grouped results. Empty categories are emitted
// as empty arrays so the client never has to check for `undefined`.
type searchResponse struct {
Query string `json:"query"`
Projects []SearchResult `json:"projects"`
Deadlines []SearchResult `json:"deadlines"`
Appointments []SearchResult `json:"appointments"`
Glossary []SearchResult `json:"glossary"`
Courts []SearchResult `json:"courts"`
Checklists []SearchResult `json:"checklists"`
Links []SearchResult `json:"links"`
Users []SearchResult `json:"users"`
}
// GET /api/search?q=<query>
func handleSearch(w http.ResponseWriter, r *http.Request) {
q := strings.TrimSpace(r.URL.Query().Get("q"))
if q == "" {
writeJSON(w, http.StatusOK, emptySearchResponse())
return
}
if n := len([]rune(q)); n > maxQueryRunes {
q = string([]rune(q)[:maxQueryRunes])
}
needle := strings.ToLower(q)
resp := emptySearchResponse()
resp.Query = q
resp.Glossary = searchGlossary(needle)
resp.Courts = searchCourts(needle)
resp.Checklists = searchChecklists(needle)
resp.Links = searchLinks(needle)
// DB-backed categories only light up when the database is wired AND the
// caller is authenticated. The route lives behind the auth middleware so
// a missing user ID means the JWT never carried one — fall back to the
// static results rather than 401ing (the static hits are still useful).
if dbSvc != nil {
if uid, ok := auth.UserIDFromContext(r.Context()); ok {
ctx := r.Context()
resp.Projects = searchProjects(ctx, uid, q)
resp.Deadlines = searchDeadlines(ctx, uid, needle)
resp.Appointments = searchAppointments(ctx, uid, needle)
resp.Checklists = append(resp.Checklists, searchChecklistInstances(ctx, uid, needle)...)
resp.Users = searchUsers(ctx, needle)
}
}
capCategory(&resp.Projects)
capCategory(&resp.Deadlines)
capCategory(&resp.Appointments)
capCategory(&resp.Glossary)
capCategory(&resp.Courts)
capCategory(&resp.Checklists)
capCategory(&resp.Links)
capCategory(&resp.Users)
capTotalBudget(&resp)
writeJSON(w, http.StatusOK, resp)
}
func emptySearchResponse() searchResponse {
return searchResponse{
Projects: []SearchResult{},
Deadlines: []SearchResult{},
Appointments: []SearchResult{},
Glossary: []SearchResult{},
Courts: []SearchResult{},
Checklists: []SearchResult{},
Links: []SearchResult{},
Users: []SearchResult{},
}
}
// ----------------------------------------------------------------------------
// Static-content searchers — scan curated Go slices, no DB.
// ----------------------------------------------------------------------------
func searchGlossary(needle string) []SearchResult {
if needle == "" {
return []SearchResult{}
}
out := make([]SearchResult, 0, maxPerCategory)
for _, term := range glossaryTerms {
if len(out) >= maxPerCategory {
break
}
if matchesAny(needle, term.DE, term.EN, term.Definition) {
out = append(out, SearchResult{
ID: term.DE,
Type: "glossary",
Title: term.DE,
Subtitle: term.EN,
URL: "/glossary?q=" + url.QueryEscape(term.DE),
})
}
}
return out
}
func searchCourts(needle string) []SearchResult {
if needle == "" {
return []SearchResult{}
}
out := make([]SearchResult, 0, maxPerCategory)
for _, c := range courts {
if len(out) >= maxPerCategory {
break
}
if matchesAny(needle, c.NameDE, c.NameEN, c.City, c.Type, c.Group, c.Country) {
subtitle := c.City
if c.Group != "" {
if subtitle != "" {
subtitle += " · " + c.Group
} else {
subtitle = c.Group
}
}
out = append(out, SearchResult{
ID: c.ID,
Type: "court",
Title: c.NameDE,
Subtitle: subtitle,
URL: "/courts?q=" + url.QueryEscape(c.NameDE),
})
}
}
return out
}
func searchChecklists(needle string) []SearchResult {
if needle == "" {
return []SearchResult{}
}
out := make([]SearchResult, 0, maxPerCategory)
for _, tmpl := range checklists.Templates {
if len(out) >= maxPerCategory {
break
}
if matchesAny(needle, tmpl.TitleDE, tmpl.TitleEN, tmpl.DescriptionDE, tmpl.DescriptionEN, tmpl.Regime) {
subtitle := tmpl.Regime
if tmpl.CourtDE != "" {
if subtitle != "" {
subtitle += " · " + tmpl.CourtDE
} else {
subtitle = tmpl.CourtDE
}
}
out = append(out, SearchResult{
ID: tmpl.Slug,
Type: "checklist_template",
Title: tmpl.TitleDE,
Subtitle: subtitle,
URL: "/checklists/" + tmpl.Slug,
})
}
}
return out
}
func searchLinks(needle string) []SearchResult {
if needle == "" {
return []SearchResult{}
}
out := make([]SearchResult, 0, maxPerCategory)
for _, l := range curatedLinks {
if len(out) >= maxPerCategory {
break
}
if matchesAny(needle, l.Title, l.DescDE, l.DescEN, l.Category) {
out = append(out, SearchResult{
ID: l.ID,
Type: "link",
Title: l.Title,
Subtitle: l.DescDE,
URL: "/links?q=" + url.QueryEscape(l.Title),
})
}
}
return out
}
// ----------------------------------------------------------------------------
// DB-backed searchers. All gate through the same visibility predicates the
// category's normal list endpoint already uses.
// ----------------------------------------------------------------------------
func searchProjects(ctx context.Context, uid uuid.UUID, q string) []SearchResult {
rows, err := dbSvc.projects.List(ctx, uid, services.ProjectFilter{Search: q})
if err != nil {
return []SearchResult{}
}
out := make([]SearchResult, 0, maxPerCategory)
for _, p := range rows {
if len(out) >= maxPerCategory {
break
}
subtitle := ""
if p.Reference != nil && *p.Reference != "" {
subtitle = *p.Reference
}
if p.ClientNumber != nil && *p.ClientNumber != "" {
if subtitle != "" {
subtitle += " · "
}
subtitle += *p.ClientNumber
if p.MatterNumber != nil && *p.MatterNumber != "" {
subtitle += "." + *p.MatterNumber
}
}
if subtitle == "" {
subtitle = humanProjectType(p.Type)
}
out = append(out, SearchResult{
ID: p.ID.String(),
Type: "project",
Title: p.Title,
Subtitle: subtitle,
URL: "/projects/" + p.ID.String(),
})
}
return out
}
// searchDeadlines filters the user's visible deadline set in Go. DeadlineService
// has no dedicated search method, but ListVisibleForUser already gates by team
// membership so the working set is bounded to what the user can see.
func searchDeadlines(ctx context.Context, uid uuid.UUID, needle string) []SearchResult {
rows, err := dbSvc.deadline.ListVisibleForUser(ctx, uid, services.ListFilter{})
if err != nil {
return []SearchResult{}
}
out := make([]SearchResult, 0, maxPerCategory)
for _, d := range rows {
if len(out) >= maxPerCategory {
break
}
note := ""
if d.Notes != nil {
note = *d.Notes
}
rule := ""
if d.RuleCode != nil {
rule = *d.RuleCode
}
if !matchesAny(needle, d.Title, note, rule) {
continue
}
subtitle := d.ProjectTitle
if !d.DueDate.IsZero() {
if subtitle != "" {
subtitle += " · "
}
subtitle += d.DueDate.Format("02.01.2006")
}
out = append(out, SearchResult{
ID: d.ID.String(),
Type: "deadline",
Title: d.Title,
Subtitle: subtitle,
URL: "/deadlines/" + d.ID.String(),
})
}
return out
}
func searchAppointments(ctx context.Context, uid uuid.UUID, needle string) []SearchResult {
rows, err := dbSvc.appointment.ListVisibleForUser(ctx, uid, services.AppointmentListFilter{})
if err != nil {
return []SearchResult{}
}
out := make([]SearchResult, 0, maxPerCategory)
for _, a := range rows {
if len(out) >= maxPerCategory {
break
}
desc := ""
if a.Description != nil {
desc = *a.Description
}
loc := ""
if a.Location != nil {
loc = *a.Location
}
if !matchesAny(needle, a.Title, loc, desc) {
continue
}
subtitle := a.StartAt.Local().Format("02.01.2006 15:04")
if loc != "" {
subtitle += " · " + loc
}
out = append(out, SearchResult{
ID: a.ID.String(),
Type: "appointment",
Title: a.Title,
Subtitle: subtitle,
URL: "/appointments/" + a.ID.String(),
})
}
return out
}
func searchChecklistInstances(ctx context.Context, uid uuid.UUID, needle string) []SearchResult {
out := make([]SearchResult, 0, maxPerCategory)
for _, tmpl := range checklists.Templates {
if len(out) >= maxPerCategory {
break
}
rows, err := dbSvc.checklistInst.ListForTemplate(ctx, uid, tmpl.Slug)
if err != nil {
continue
}
for _, inst := range rows {
if len(out) >= maxPerCategory {
break
}
if !matchesAny(needle, inst.Name) {
continue
}
subtitle := tmpl.TitleDE
if inst.ProjectTitle != nil && *inst.ProjectTitle != "" {
subtitle += " · " + *inst.ProjectTitle
}
out = append(out, SearchResult{
ID: inst.ID.String(),
Type: "checklist_instance",
Title: inst.Name,
Subtitle: subtitle,
URL: "/checklists/instances/" + inst.ID.String(),
})
}
}
return out
}
func searchUsers(ctx context.Context, needle string) []SearchResult {
rows, err := dbSvc.users.List(ctx)
if err != nil {
return []SearchResult{}
}
out := make([]SearchResult, 0, maxPerCategory)
for _, u := range rows {
if len(out) >= maxPerCategory {
break
}
if !matchesAny(needle, u.DisplayName, u.Email, u.Office) {
continue
}
subtitle := u.Email
if u.Office != "" {
subtitle += " · " + u.Office
}
out = append(out, SearchResult{
ID: u.ID.String(),
Type: "user",
Title: u.DisplayName,
Subtitle: subtitle,
// No dedicated user profile page exists yet; link to mail
// so the entry is clickable and actionable.
URL: "mailto:" + u.Email,
})
}
return out
}
// ----------------------------------------------------------------------------
// Helpers
// ----------------------------------------------------------------------------
// matchesAny returns true if the (already-lowercased) needle appears in any of
// the haystacks after lowercasing. Empty needle matches nothing.
func matchesAny(needle string, haystacks ...string) bool {
if needle == "" {
return false
}
for _, h := range haystacks {
if h == "" {
continue
}
if strings.Contains(strings.ToLower(h), needle) {
return true
}
}
return false
}
// capCategory trims a result slice to maxPerCategory in place.
func capCategory(rs *[]SearchResult) {
if len(*rs) > maxPerCategory {
*rs = (*rs)[:maxPerCategory]
}
}
// capTotalBudget enforces the overall maxTotalResults ceiling. Trims from the
// largest category repeatedly so the final mix stays balanced across surfaces.
func capTotalBudget(r *searchResponse) {
groups := []*[]SearchResult{
&r.Projects, &r.Deadlines, &r.Appointments,
&r.Glossary, &r.Courts, &r.Checklists,
&r.Links, &r.Users,
}
total := 0
for _, g := range groups {
total += len(*g)
}
for total > maxTotalResults {
biggest := -1
biggestLen := 1
for i, g := range groups {
if len(*g) > biggestLen {
biggest = i
biggestLen = len(*g)
}
}
if biggest < 0 {
return
}
g := groups[biggest]
*g = (*g)[:len(*g)-1]
total--
}
}
// humanProjectType maps the DB enum to a display label. Used when a project
// has no reference / client_number to show in the subtitle.
func humanProjectType(t string) string {
switch t {
case services.ProjectTypeClient:
return "Mandant"
case services.ProjectTypeLitigation:
return "Litigation"
case services.ProjectTypePatent:
return "Patent"
case services.ProjectTypeCase:
return "Verfahren"
case services.ProjectTypeProject:
return "Projekt"
case services.ProjectTypeOther:
return "Sonstiges"
}
return t
}