Source identity
| Field | Recorded value |
|---|---|
| Source type | GitHub source |
| Original record | Janady13/missionmatch / internal/modules/matching/lists.go |
| Date | Not specified in source |
| Repository | Janady13/missionmatch |
| Revision | 720d47fae476b0d38477ae4401697d372b1ea2bc |
| Branch | feature/opportunity-to-outcome |
| Path or archive member | internal/modules/matching/lists.go |
| Original SHA-256 | a1dfb3d765810182c95cef1fd8bc551be9c2e40b922ba7c24d36dc82b375d232 |
| Git blob SHA | 966eb33e8910b4643b0f4be09bb9e8fcb8752e9a |
| Reading-copy SHA-256 | a1dfb3d765810182c95cef1fd8bc551be9c2e40b922ba7c24d36dc82b375d232 |
Recovered source transcript
Download complete source text ↓434 lines · 15.1 KB
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
})
}