F-6 from t-paliad-074 architecture audit. The Gitea repo was renamed m/patholo → mAi/paliad → m/paliad, but go.mod still declared `mgit.msbls.de/m/patholo` and every internal import echoed the pre-rebrand name. Sweep: - go.mod: module path → mgit.msbls.de/m/paliad - All *.go files: imports rewritten via sed - README.md, docs/design-kanzlai-integration.md: mAi/paliad → m/paliad - Frontend issue-reference comments (mAi/paliad#N → m/paliad#N) in i18n.ts, theme.ts, sidebar.ts, app.ts, Sidebar.tsx, PWAHead.tsx, global.css Verified: go build/vet/test ./... clean, bun run build clean, no remaining mgit.msbls.de/m/patholo or mAi/paliad references outside docs that intentionally describe the rename history.
479 lines
13 KiB
Go
479 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"
|
|
}
|
|
return t
|
|
}
|