feat(parties): cross-project party search endpoint for submission picker (t-paliad-287)
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.
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user