package matching // LISTS AND SAVED SEARCHES, plus the bulk actions that make them worth having. // // A SAVED SEARCH IS A QUESTION and its answer changes as the bench changes. A LIST IS AN ANSWER somebody // committed to and it must not change underneath them. That is why they are two things: an agency that // shortlists nine people for a bid needs those nine to still be those nine next week, and a recruiter running // the same morning query needs today's results, not March's. // // BULK IS THE POINT. Selecting forty people from a search and putting them on a list in one action is the // difference between a tool and a form. Every bulk operation here is ONE TRANSACTION and reports per record // outcomes, so a partial result is a fact the caller can see rather than a silent half success. import ( "context" "encoding/json" "errors" "fmt" "strings" "time" "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/missionworkforce/missionmatch/internal/platform/audit" ) var ( ErrListUnknown = errors.New("matching: no such list") ErrSearchUnknown = errors.New("matching: no such saved search") ErrTooManyIDs = errors.New("matching: too many records in one bulk action") ) // MaxBulkIDs bounds a single bulk action. Two hundred is more than a person selects by hand and small enough // that one transaction stays short, which matters because a long transaction holds locks a whole floor waits // on. const MaxBulkIDs = 200 type CandidateList struct { ID string `json:"id"` Name string `json:"name"` Description string `json:"description"` Purpose string `json:"purpose"` Visibility string `json:"visibility"` Status string `json:"status"` OwnerUserID string `json:"owner_user_id"` OwnerName string `json:"owner_name,omitempty"` Members int `json:"members"` CreatedAt time.Time `json:"created_at"` } type SavedSearch struct { ID string `json:"id"` Name string `json:"name"` Visibility string `json:"visibility"` Pinned bool `json:"pinned"` OwnerUserID string `json:"owner_user_id"` OwnerName string `json:"owner_name,omitempty"` Criteria CandidateQuery `json:"criteria"` LastRunAt *time.Time `json:"last_run_at,omitempty"` CreatedAt time.Time `json:"created_at"` } // BulkResult reports what happened per record rather than a single number, because "added 37 of 40" without // saying which three is an answer nobody can act on. type BulkResult struct { Requested int `json:"requested"` Applied int `json:"applied"` Skipped int `json:"skipped"` SkippedID []string `json:"skipped_ids,omitempty"` Note string `json:"note,omitempty"` } // ---- lists ---------------------------------------------------------------- func (s *Service) CreateList(ctx context.Context, name, description, purpose, visibility string) (string, error) { if s.db == nil { return "", ErrNoDatabase } actor := actorUUIDPtr(ctx) if actor == nil { return "", ErrNoDatabase } var id string err := s.db.RunInTenantTx(ctx, func(ctx context.Context, tx pgx.Tx) error { return tx.QueryRow(ctx, ` INSERT INTO candidate_lists (organization_id, name, description, purpose, visibility, owner_user_id) VALUES (NULLIF(current_setting('app.current_org_id', true), '')::uuid, $1,$2,$3,$4,$5) RETURNING id::text`, strings.TrimSpace(name), description, purpose, visibility, *actor).Scan(&id) }) return id, err } // ListLists returns the lists this caller may see: their own plus everything shared. A private list belonging // to somebody else is not merely hidden from the results, it is not queried, so its name never leaks through // a count or an error. func (s *Service) ListLists(ctx context.Context, includeArchived bool) ([]CandidateList, error) { if s.db == nil { return nil, ErrNoDatabase } actor := actorUUIDPtr(ctx) if actor == nil { return nil, ErrNoDatabase } out := make([]CandidateList, 0, 16) err := s.db.RunInTenantTx(ctx, func(ctx context.Context, tx pgx.Tx) error { q := `SELECT l.id::text, l.name, l.description, l.purpose, l.visibility, l.status, l.owner_user_id::text, coalesce(u.full_name,''), (SELECT count(*)::int FROM candidate_list_members m WHERE m.list_id = l.id), l.created_at FROM candidate_lists l LEFT JOIN users u ON u.id = l.owner_user_id WHERE (l.visibility = 'shared' OR l.owner_user_id = $1) AND ($2::bool OR l.status = 'active') ORDER BY l.status, lower(l.name)` rows, e := tx.Query(ctx, q, *actor, includeArchived) if e != nil { return e } defer rows.Close() for rows.Next() { var l CandidateList if e := rows.Scan(&l.ID, &l.Name, &l.Description, &l.Purpose, &l.Visibility, &l.Status, &l.OwnerUserID, &l.OwnerName, &l.Members, &l.CreatedAt); e != nil { return e } out = append(out, l) } return rows.Err() }) return out, err } // AddToList is the bulk action. Adding somebody already on the list is NOT an error, it is a skip, because a // recruiter selecting forty people from a search does not know or care which nine are already there. func (s *Service) AddToList(ctx context.Context, listID uuid.UUID, candidateIDs []uuid.UUID, note, ip, ua string) (BulkResult, error) { res := BulkResult{Requested: len(candidateIDs)} if s.db == nil { return res, ErrNoDatabase } if len(candidateIDs) > MaxBulkIDs { return res, fmt.Errorf("%w: %d, the cap is %d", ErrTooManyIDs, len(candidateIDs), MaxBulkIDs) } if len(candidateIDs) == 0 { return res, nil } if len(note) > 500 { note = note[:500] } actor := actorUUIDPtr(ctx) err := s.db.RunInTenantTx(ctx, func(ctx context.Context, tx pgx.Tx) error { if e := assertList(ctx, tx, listID); e != nil { return e } // ONE STATEMENT for the whole batch. A loop of forty inserts is forty round trips and forty chances // for a partial failure; unnest plus a select makes it one, and ON CONFLICT DO NOTHING turns "already // on the list" into a skip rather than an abort. tag, e := tx.Exec(ctx, ` INSERT INTO candidate_list_members (organization_id, list_id, candidate_id, note, added_by) SELECT NULLIF(current_setting('app.current_org_id', true), '')::uuid, $1, c.id, $3, $4 FROM unnest($2::uuid[]) AS want(id) JOIN candidates c ON c.id = want.id AND c.deleted_at IS NULL ON CONFLICT DO NOTHING`, listID, candidateIDs, note, actor) if e != nil { return e } res.Applied = int(tag.RowsAffected()) res.Skipped = res.Requested - res.Applied if res.Skipped > 0 { res.Note = "skipped records were already on the list or are not candidates in this organization" } if s.aud != nil { body, _ := json.Marshal(map[string]any{ "list_id": listID.String(), "requested": res.Requested, "applied": res.Applied, }) return s.aud.OnTx(ctx, tx, audit.Event{ Action: "candidate.list.members_added", ActorUserID: actor, EntityType: "candidate_list", EntityID: listID.String(), NewValues: body, IPAddress: ip, UserAgent: ua, }) } return nil }) return res, err } func (s *Service) RemoveFromList(ctx context.Context, listID uuid.UUID, candidateIDs []uuid.UUID, ip, ua string) (BulkResult, error) { res := BulkResult{Requested: len(candidateIDs)} if s.db == nil { return res, ErrNoDatabase } if len(candidateIDs) > MaxBulkIDs { return res, fmt.Errorf("%w: %d, the cap is %d", ErrTooManyIDs, len(candidateIDs), MaxBulkIDs) } if len(candidateIDs) == 0 { return res, nil } actor := actorUUIDPtr(ctx) err := s.db.RunInTenantTx(ctx, func(ctx context.Context, tx pgx.Tx) error { if e := assertList(ctx, tx, listID); e != nil { return e } tag, e := tx.Exec(ctx, `DELETE FROM candidate_list_members WHERE list_id = $1 AND candidate_id = ANY($2)`, listID, candidateIDs) if e != nil { return e } res.Applied = int(tag.RowsAffected()) res.Skipped = res.Requested - res.Applied if s.aud != nil { body, _ := json.Marshal(map[string]any{ "list_id": listID.String(), "requested": res.Requested, "removed": res.Applied, }) return s.aud.OnTx(ctx, tx, audit.Event{ Action: "candidate.list.members_removed", ActorUserID: actor, EntityType: "candidate_list", EntityID: listID.String(), OldValues: body, IPAddress: ip, UserAgent: ua, }) } return nil }) return res, err } // BulkAssignOwner puts many candidates on one person's desk. A nil owner returns them all to the shared pool. // // THIS IS AN ACCESS CHANGE under a narrowed record scope, so it is audited with the count and the target, and // it is bounded by the same cap as every other bulk action. func (s *Service) BulkAssignOwner(ctx context.Context, candidateIDs []uuid.UUID, owner *uuid.UUID, ip, ua string) (BulkResult, error) { res := BulkResult{Requested: len(candidateIDs)} if s.db == nil { return res, ErrNoDatabase } if len(candidateIDs) > MaxBulkIDs { return res, fmt.Errorf("%w: %d, the cap is %d", ErrTooManyIDs, len(candidateIDs), MaxBulkIDs) } if len(candidateIDs) == 0 { return res, nil } actor := actorUUIDPtr(ctx) err := s.db.RunInTenantTx(ctx, func(ctx context.Context, tx pgx.Tx) error { tag, e := tx.Exec(ctx, `UPDATE candidates SET owner_user_id = $2, updated_at = now() WHERE id = ANY($1) AND deleted_at IS NULL`, candidateIDs, owner) if e != nil { return e } res.Applied = int(tag.RowsAffected()) res.Skipped = res.Requested - res.Applied if s.aud != nil { body, _ := json.Marshal(map[string]any{ "requested": res.Requested, "applied": res.Applied, "owner_user_id": uuidText(owner), }) return s.aud.OnTx(ctx, tx, audit.Event{ Action: "candidate.owner.bulk_changed", ActorUserID: actor, EntityType: "candidate", NewValues: body, IPAddress: ip, UserAgent: ua, }) } return nil }) return res, err } // ListsForCandidate answers "which lists is this person on", asked on every candidate screen. func (s *Service) ListsForCandidate(ctx context.Context, candidateID uuid.UUID) ([]CandidateList, error) { if s.db == nil { return nil, ErrNoDatabase } actor := actorUUIDPtr(ctx) if actor == nil { return nil, ErrNoDatabase } out := make([]CandidateList, 0, 8) err := s.db.RunInTenantTx(ctx, func(ctx context.Context, tx pgx.Tx) error { rows, e := tx.Query(ctx, ` SELECT l.id::text, l.name, l.purpose, l.visibility, l.status FROM candidate_list_members m JOIN candidate_lists l ON l.id = m.list_id WHERE m.candidate_id = $1 AND (l.visibility = 'shared' OR l.owner_user_id = $2) ORDER BY lower(l.name)`, candidateID, *actor) if e != nil { return e } defer rows.Close() for rows.Next() { var l CandidateList if e := rows.Scan(&l.ID, &l.Name, &l.Purpose, &l.Visibility, &l.Status); e != nil { return e } out = append(out, l) } return rows.Err() }) return out, err } func assertList(ctx context.Context, tx pgx.Tx, listID uuid.UUID) error { var status string e := tx.QueryRow(ctx, `SELECT status FROM candidate_lists WHERE id = $1`, listID).Scan(&status) if errors.Is(e, pgx.ErrNoRows) { return ErrListUnknown } if e != nil { return e } if status != "active" { // An archived list is a historical record. Adding to it would rewrite what a shortlist said at the // time somebody relied on it. return fmt.Errorf("%w: that list is archived", ErrListUnknown) } return nil } // ---- saved searches ------------------------------------------------------- func (s *Service) SaveSearch(ctx context.Context, name, visibility string, q CandidateQuery, pinned bool) (string, error) { if s.db == nil { return "", ErrNoDatabase } actor := actorUUIDPtr(ctx) if actor == nil { return "", ErrNoDatabase } crit, err := MarshalCriteria(q) if err != nil { return "", err } var id string err = s.db.RunInTenantTx(ctx, func(ctx context.Context, tx pgx.Tx) error { return tx.QueryRow(ctx, ` INSERT INTO saved_searches (organization_id, name, owner_user_id, visibility, criteria, pinned) VALUES (NULLIF(current_setting('app.current_org_id', true), '')::uuid, $1,$2,$3,$4,$5) RETURNING id::text`, strings.TrimSpace(name), *actor, visibility, crit, pinned).Scan(&id) }) return id, err } func (s *Service) ListSavedSearches(ctx context.Context) ([]SavedSearch, error) { if s.db == nil { return nil, ErrNoDatabase } actor := actorUUIDPtr(ctx) if actor == nil { return nil, ErrNoDatabase } out := make([]SavedSearch, 0, 16) err := s.db.RunInTenantTx(ctx, func(ctx context.Context, tx pgx.Tx) error { rows, e := tx.Query(ctx, ` SELECT s.id::text, s.name, s.visibility, s.pinned, s.owner_user_id::text, coalesce(u.full_name,''), s.criteria, s.last_run_at, s.created_at FROM saved_searches s LEFT JOIN users u ON u.id = s.owner_user_id WHERE s.visibility = 'shared' OR s.owner_user_id = $1 ORDER BY s.pinned DESC, lower(s.name)`, *actor) if e != nil { return e } defer rows.Close() for rows.Next() { var ss SavedSearch var crit []byte if e := rows.Scan(&ss.ID, &ss.Name, &ss.Visibility, &ss.Pinned, &ss.OwnerUserID, &ss.OwnerName, &crit, &ss.LastRunAt, &ss.CreatedAt); e != nil { return e } // A criteria blob that will not parse does not break the list. The search is returned with an // empty filter set and the interface shows it as needing attention, because one corrupt row must // not hide the other thirty nine. ss.Criteria, _ = UnmarshalCriteria(crit) out = append(out, ss) } return rows.Err() }) return out, err } // RunSavedSearch loads a saved search, RE VALIDATES its criteria, and runs it. Stored criteria are user input // that has been sitting in a table, so they go back through Normalize exactly like a fresh request. func (s *Service) RunSavedSearch(ctx context.Context, id uuid.UUID, limit, offset int) (CandidateSearchResult, error) { var q CandidateQuery if s.db == nil { return CandidateSearchResult{}, ErrNoDatabase } actor := actorUUIDPtr(ctx) if actor == nil { return CandidateSearchResult{}, ErrNoDatabase } err := s.db.RunInTenantTx(ctx, func(ctx context.Context, tx pgx.Tx) error { var crit []byte e := tx.QueryRow(ctx, `SELECT criteria FROM saved_searches WHERE id = $1 AND (visibility = 'shared' OR owner_user_id = $2)`, id, *actor).Scan(&crit) if errors.Is(e, pgx.ErrNoRows) { return ErrSearchUnknown } if e != nil { return e } q, e = UnmarshalCriteria(crit) if e != nil { return e } _, e = tx.Exec(ctx, `UPDATE saved_searches SET last_run_at = now() WHERE id = $1`, id) return e }) if err != nil { return CandidateSearchResult{}, err } q.Limit, q.Offset = limit, offset return s.SearchCandidates(ctx, q) } func (s *Service) DeleteSavedSearch(ctx context.Context, id uuid.UUID) error { if s.db == nil { return ErrNoDatabase } actor := actorUUIDPtr(ctx) if actor == nil { return ErrNoDatabase } return s.db.RunInTenantTx(ctx, func(ctx context.Context, tx pgx.Tx) error { // ONLY THE OWNER may delete, even for a shared search. A shared search other people rely on is not // something a colleague should be able to remove from under them. tag, e := tx.Exec(ctx, `DELETE FROM saved_searches WHERE id = $1 AND owner_user_id = $2`, id, *actor) if e != nil { return e } if tag.RowsAffected() == 0 { return ErrSearchUnknown } return nil }) }