From d4ed989b8fc413159c2526c5bbb9c0ec975c5225 Mon Sep 17 00:00:00 2001 From: mAi Date: Tue, 26 May 2026 09:41:07 +0200 Subject: [PATCH] feat(parties): cross-project party search endpoint for submission picker (t-paliad-287) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds PartyService.Search returning paliad.parties rows from every project the caller can see, matched by case-insensitive substring on name or representative. Wired via GET /api/parties/search?q=... — used by the submission-draft Add-Party panel's "Aus DB übernehmen" tab. Visibility flows through the same visibilityPredicatePositional helper every project-scoped read uses; invisible projects' parties never surface. Capped at 25 hits per call (no pagination — typical lookup is "the party I'm thinking of by name", not a browse). Result shape carries project_title + project_reference so the picker can disambiguate identically-named parties across cases. --- internal/handlers/handlers.go | 1 + internal/handlers/projects.go | 25 ++++++++++++++ internal/services/party_service.go | 53 ++++++++++++++++++++++++++++++ 3 files changed, 79 insertions(+) diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index f7ad569..6912f16 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -458,6 +458,7 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc // t-paliad-139 — set unit_role on a member. protected.HandleFunc("PATCH /api/partner-units/{id}/members/{user_id}/role", handleSetUnitMemberRole) + protected.HandleFunc("GET /api/parties/search", handlePartiesSearch) protected.HandleFunc("DELETE /api/parties/{id}", handleDeleteParty) // Phase F — Appointments (appointments) diff --git a/internal/handlers/projects.go b/internal/handlers/projects.go index 9a8416c..208f64c 100644 --- a/internal/handlers/projects.go +++ b/internal/handlers/projects.go @@ -701,6 +701,31 @@ func handleCreateParty(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusCreated, p) } +// GET /api/parties/search?q=... +// +// Cross-project party picker for the submission-draft editor +// (t-paliad-287). Returns up to 25 parties from every project the +// caller can see, matched by case-insensitive substring on name or +// representative. Empty q returns the 20 most-recently-updated rows so +// the picker isn't blank on first open. Visibility is enforced in the +// service layer via the same predicate every project-scoped read uses. +func handlePartiesSearch(w http.ResponseWriter, r *http.Request) { + if !requireDB(w) { + return + } + uid, ok := requireUser(w, r) + if !ok { + return + } + q := r.URL.Query().Get("q") + hits, err := dbSvc.parties.Search(r.Context(), uid, q, 25) + if err != nil { + writeServiceError(w, err) + return + } + writeJSON(w, http.StatusOK, map[string]any{"results": hits}) +} + // DELETE /api/parties/{id} func handleDeleteParty(w http.ResponseWriter, r *http.Request) { if !requireDB(w) { diff --git a/internal/services/party_service.go b/internal/services/party_service.go index af41159..9f1510b 100644 --- a/internal/services/party_service.go +++ b/internal/services/party_service.go @@ -38,6 +38,59 @@ type CreatePartyInput struct { ContactInfo json.RawMessage `json:"contact_info,omitempty"` } +// PartySearchHit is one row of the cross-project party search — a real +// paliad.parties row enriched with the parent project's title and +// reference so the picker can render context the lawyer needs to +// disambiguate identically-named parties on different cases +// (t-paliad-287). +type PartySearchHit struct { + ID uuid.UUID `db:"id" json:"id"` + ProjectID uuid.UUID `db:"project_id" json:"project_id"` + ProjectTitle string `db:"project_title" json:"project_title"` + ProjectReference *string `db:"project_reference" json:"project_reference,omitempty"` + Name string `db:"name" json:"name"` + Role *string `db:"role" json:"role,omitempty"` + Representative *string `db:"representative" json:"representative,omitempty"` +} + +// Search returns parties from every project the caller can see, matched +// by case-insensitive substring on name OR representative. Empty query +// returns the 20 most recently-updated parties so the picker isn't +// blank on first open. Capped at 25 rows; the frontend doesn't paginate +// (the typical PA looks for one party they remember by name, not browses). +// +// Visibility is enforced inline via visibilityPredicatePositional — +// invisible projects' parties never surface in the result set. +func (s *PartyService) Search(ctx context.Context, userID uuid.UUID, query string, limit int) ([]PartySearchHit, error) { + if limit <= 0 || limit > 50 { + limit = 25 + } + q := strings.TrimSpace(query) + args := []any{userID} + conds := []string{visibilityPredicatePositional("p", 1)} + if q != "" { + args = append(args, "%"+q+"%") + conds = append(conds, + fmt.Sprintf(`(pa.name ILIKE $%d OR COALESCE(pa.representative,'') ILIKE $%d)`, + len(args), len(args))) + } + args = append(args, limit) + sqlStr := ` + SELECT pa.id, pa.project_id, p.title AS project_title, + p.reference AS project_reference, + pa.name, pa.role, pa.representative + FROM paliad.parties pa + JOIN paliad.projects p ON p.id = pa.project_id + WHERE ` + strings.Join(conds, " AND ") + ` + ORDER BY pa.updated_at DESC + LIMIT $` + fmt.Sprintf("%d", len(args)) + hits := []PartySearchHit{} + if err := s.db.SelectContext(ctx, &hits, sqlStr, args...); err != nil { + return nil, fmt.Errorf("search parties: %w", err) + } + return hits, nil +} + // ListForProject returns all Parties for the Project, visibility-checked. func (s *PartyService) ListForProject(ctx context.Context, userID, projectID uuid.UUID) ([]models.Party, error) { if _, err := s.projects.GetByID(ctx, userID, projectID); err != nil {