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.
175 lines
5.8 KiB
Go
175 lines
5.8 KiB
Go
package services
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/jmoiron/sqlx"
|
|
|
|
"mgit.msbls.de/m/paliad/internal/models"
|
|
)
|
|
|
|
// PartyService reads and writes paliad.parties. Visibility inherits from
|
|
// the parent Project.
|
|
type PartyService struct {
|
|
db *sqlx.DB
|
|
projects *ProjectService
|
|
}
|
|
|
|
// NewPartyService wires the service.
|
|
func NewPartyService(db *sqlx.DB, projects *ProjectService) *PartyService {
|
|
return &PartyService{db: db, projects: projects}
|
|
}
|
|
|
|
const partyColumns = `id, project_id, name, role, representative, contact_info,
|
|
created_at, updated_at`
|
|
|
|
// CreatePartyInput is the payload for Create.
|
|
type CreatePartyInput struct {
|
|
Name string `json:"name"`
|
|
Role *string `json:"role,omitempty"`
|
|
Representative *string `json:"representative,omitempty"`
|
|
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 {
|
|
return nil, err
|
|
}
|
|
rows := []models.Party{}
|
|
if err := s.db.SelectContext(ctx, &rows,
|
|
`SELECT `+partyColumns+`
|
|
FROM paliad.parties
|
|
WHERE project_id = $1
|
|
ORDER BY name`, projectID); err != nil {
|
|
return nil, fmt.Errorf("list parties: %w", err)
|
|
}
|
|
return rows, nil
|
|
}
|
|
|
|
// Create inserts a Party under a Project; visibility is checked on the parent.
|
|
func (s *PartyService) Create(ctx context.Context, userID, projectID uuid.UUID, input CreatePartyInput) (*models.Party, error) {
|
|
if strings.TrimSpace(input.Name) == "" {
|
|
return nil, fmt.Errorf("%w: name is required", ErrInvalidInput)
|
|
}
|
|
if _, err := s.projects.GetByID(ctx, userID, projectID); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
contact := input.ContactInfo
|
|
if len(contact) == 0 {
|
|
contact = json.RawMessage(`{}`)
|
|
}
|
|
|
|
id := uuid.New()
|
|
now := time.Now().UTC()
|
|
if _, err := s.db.ExecContext(ctx,
|
|
`INSERT INTO paliad.parties
|
|
(id, project_id, name, role, representative, contact_info,
|
|
created_at, updated_at)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $7)`,
|
|
id, projectID, input.Name, input.Role, input.Representative, contact, now,
|
|
); err != nil {
|
|
return nil, fmt.Errorf("insert party: %w", err)
|
|
}
|
|
|
|
var p models.Party
|
|
if err := s.db.GetContext(ctx, &p,
|
|
`SELECT `+partyColumns+` FROM paliad.parties WHERE id = $1`, id); err != nil {
|
|
return nil, fmt.Errorf("fetch created party: %w", err)
|
|
}
|
|
return &p, nil
|
|
}
|
|
|
|
// Delete removes a Party. Partner/admin only.
|
|
func (s *PartyService) Delete(ctx context.Context, userID, partyID uuid.UUID) error {
|
|
user, err := s.projects.Users().GetByID(ctx, userID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if user == nil {
|
|
return ErrNotVisible
|
|
}
|
|
if user.GlobalRole != "global_admin" {
|
|
return fmt.Errorf("%w: only partners/admins can delete Parties", ErrForbidden)
|
|
}
|
|
|
|
var projectID uuid.UUID
|
|
err = s.db.GetContext(ctx, &projectID,
|
|
`SELECT project_id FROM paliad.parties WHERE id = $1`, partyID)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return ErrNotVisible
|
|
}
|
|
if err != nil {
|
|
return fmt.Errorf("lookup party parent: %w", err)
|
|
}
|
|
if _, err := s.projects.GetByID(ctx, userID, projectID); err != nil {
|
|
return err
|
|
}
|
|
if _, err := s.db.ExecContext(ctx,
|
|
`DELETE FROM paliad.parties WHERE id = $1`, partyID); err != nil {
|
|
return fmt.Errorf("delete party: %w", err)
|
|
}
|
|
return nil
|
|
}
|