package recruiting import ( "context" "encoding/json" "errors" "strings" "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/missionworkforce/missionmatch/internal/platform/audit" "github.com/missionworkforce/missionmatch/internal/platform/tenant" ) // REASSIGNMENT, THE OTHER HALF OF THE MANAGER VIEW. // // Phase 1 tells a manager that Rex is holding thirty one open orders and is over the benchmark. It gives // them no way to do anything about it, which makes it a report rather than a tool. This is the acting half. // // TWO GAPS WERE IN THE WAY AND BOTH WERE REAL. // // First, a CLIENT could be reassigned and a JOB ORDER could not. Clients carried claim, release and // reassign; requisitions carried claim and release and stopped. So a manager could move the account and // then watch the work on it stay with the person who was already drowning. The asymmetry was invisible // because each route looked complete on its own. // // Second, and worse, there was no way to move a DESK. Reassigning one record at a time is fine for a // correction and useless for the thing managers actually do, which is a person leaving on Friday and // their whole book needing a home by Monday. Doing that through single record routes is hundreds of // clicks, and the failure mode is not tedium, it is the twelve records somebody missed. // deskMoveCeiling bounds one desk move. It is a REFUSAL, not a truncation. // // This is the third time in this build that an unbounded set has been the defect: the alerts feed shipped // 7.9 MB to a six row panel, and the key personnel list shipped 9 MB. Both were fixed by capping the read. // A WRITE cannot be fixed that way. Silently moving the first five hundred records of a desk and reporting // success would leave a manager believing a departing recruiter's book was handled when a tail of it was // still pointing at somebody who no longer works there. So this counts first and refuses above the ceiling // with the real number, and the manager narrows the move themselves. const deskMoveCeiling = 5000 // MovableKinds is the vocabulary. Named once so the handler, the validator and the interface cannot drift // apart the way the alert type list drifted from the database CHECK earlier in this build. var MovableKinds = []string{"clients", "requisitions", "applications"} // DeskMove is the result of moving one recruiter's work to another. Counts are what ACTUALLY changed // hands, read back from the update rather than assumed from the request. type DeskMove struct { FromUserID string `json:"from_user_id"` FromName string `json:"from_name,omitempty"` ToUserID string `json:"to_user_id"` ToName string `json:"to_name,omitempty"` Clients int `json:"clients"` Requisitions int `json:"requisitions"` Applications int `json:"applications"` Total int `json:"total"` } // ReassignRequisition moves one job order to another recruiter. // // The client equivalent has existed since ownership was built. This is the missing twin, deliberately // written to the same shape so the two behave identically rather than each inventing its own rules. func (s *Service) ReassignRequisition(ctx context.Context, id, toUser uuid.UUID) (Requisition, error) { if s.db == nil { return Requisition{}, ErrNoDatabase } if !canOverride(ctx) { return Requisition{}, &ValidationError{Field: "owner_user_id", Message: "only a manager or above can reassign a job order"} } err := s.db.RunInTenantTx(ctx, func(ctx context.Context, tx pgx.Tx) error { var exists bool if e := tx.QueryRow(ctx, `SELECT true FROM requisitions WHERE id = $1 AND deleted_at IS NULL FOR UPDATE`, id).Scan(&exists); e != nil { if errors.Is(e, pgx.ErrNoRows) { return ErrNotFound } return translatePG(e) } if e := assertActiveMember(ctx, tx, toUser); e != nil { return e } if _, e := tx.Exec(ctx, ` UPDATE requisitions SET owner_user_id = $2, updated_at = now() WHERE id = $1`, id, toUser); e != nil { return translatePG(e) } return s.auditOwnership(ctx, tx, "requisition", id, &toUser, "requisition.reassigned") }) if err != nil { return Requisition{}, err } return s.requisitionRow(ctx, id) } // assertActiveMember refuses a target who is not a live member of this tenant. // // RLS scopes the lookup, so a user id belonging to another organization does not exist here and reports as // "not a user in this organization" rather than leaking that it exists somewhere else. func assertActiveMember(ctx context.Context, tx pgx.Tx, id uuid.UUID) error { var active bool if err := tx.QueryRow(ctx, `SELECT status = 'active' FROM users WHERE id = $1`, id).Scan(&active); err != nil { if errors.Is(err, pgx.ErrNoRows) { return &ValidationError{Field: "to_user_id", Message: "is not a user in this organization"} } return translatePG(err) } if !active { return &ValidationError{Field: "to_user_id", Message: "is not an active user"} } return nil } // MoveDesk moves one recruiter's work to another in a single transaction. // // ALL OR NOTHING, ON PURPOSE. Every kind moves inside one transaction, so a manager either gets a desk // that changed hands completely or a desk that did not move at all and an error saying why. A half moved // desk is the worst of the three outcomes because it looks like the first one. func (s *Service) MoveDesk(ctx context.Context, fromRaw, toRaw string, kinds []string) (DeskMove, error) { if s.db == nil { return DeskMove{}, ErrNoDatabase } if !canOverride(ctx) { return DeskMove{}, &ValidationError{Field: "from_user_id", Message: "only a manager or above can move a desk"} } if _, ok := tenant.UserFromContext(ctx); !ok { return DeskMove{}, tenant.ErrNoTenant } from, err := uuid.Parse(strings.TrimSpace(fromRaw)) if err != nil { return DeskMove{}, &ValidationError{Field: "from_user_id", Message: "is not an identifier"} } to, err := uuid.Parse(strings.TrimSpace(toRaw)) if err != nil { return DeskMove{}, &ValidationError{Field: "to_user_id", Message: "is not an identifier"} } // Moving a desk to itself is almost always a mistake in the form rather than an intention, and letting // it through would write an audit trail saying a desk changed hands when nothing did. if from == to { return DeskMove{}, &ValidationError{Field: "to_user_id", Message: "is the same person the work is already assigned to"} } want, err := normalizeKinds(kinds) if err != nil { return DeskMove{}, err } out := DeskMove{FromUserID: from.String(), ToUserID: to.String()} err = s.db.RunInTenantTx(ctx, func(ctx context.Context, tx pgx.Tx) error { if e := assertActiveMember(ctx, tx, to); e != nil { return e } // The SOURCE is looked up but NOT required to be active. That is the main case this exists for: // somebody has left, their account is deactivated, and their work still has to go somewhere. if e := tx.QueryRow(ctx, `SELECT full_name FROM users WHERE id = $1`, from).Scan(&out.FromName); e != nil { if errors.Is(e, pgx.ErrNoRows) { return &ValidationError{Field: "from_user_id", Message: "is not a user in this organization"} } return translatePG(e) } if e := tx.QueryRow(ctx, `SELECT full_name FROM users WHERE id = $1`, to).Scan(&out.ToName); e != nil { return translatePG(e) } total, e := countDesk(ctx, tx, from, want) if e != nil { return e } if total > deskMoveCeiling { return &ValidationError{ Field: "from_user_id", Message: "holds " + itoa(total) + " records, which is more than one move may carry. Move one kind at a time", } } for _, kind := range want { ids, e := moveOwnership(ctx, tx, kind, from, to) if e != nil { return e } switch kind { case "clients": out.Clients = len(ids) case "requisitions": out.Requisitions = len(ids) case "applications": out.Applications = len(ids) } if len(ids) == 0 { continue } if e := s.auditDeskMove(ctx, tx, kind, from, to, ids); e != nil { return e } } out.Total = out.Clients + out.Requisitions + out.Applications return nil }) if err != nil { return DeskMove{}, err } return out, nil } // normalizeKinds validates the requested kinds and defaults to all of them. // // An empty request means the whole desk, which is what a manager means when somebody leaves. Naming kinds // is the narrowing case, not the normal one. func normalizeKinds(kinds []string) ([]string, error) { if len(kinds) == 0 { return MovableKinds, nil } seen := map[string]bool{} var out []string for _, k := range kinds { k = strings.ToLower(strings.TrimSpace(k)) if k == "" { continue } if !isMovableKind(k) { return nil, &ValidationError{Field: "include", Message: k + " is not something a desk holds"} } if seen[k] { continue } seen[k] = true out = append(out, k) } if len(out) == 0 { return MovableKinds, nil } return out, nil } func isMovableKind(want string) bool { for _, v := range MovableKinds { if v == want { return true } } return false } // countDesk sizes the move before any of it happens, so the ceiling refuses rather than truncates. func countDesk(ctx context.Context, tx pgx.Tx, from uuid.UUID, kinds []string) (int, error) { total := 0 for _, kind := range kinds { var n int if err := tx.QueryRow(ctx, countSQL(kind), from).Scan(&n); err != nil { return 0, translatePG(err) } total += n } return total, nil } // The predicates are written once and shared by the count and the move, so the number a manager is refused // on and the number that would actually change hands can never describe different sets. func whereSQL(kind string) string { switch kind { case "clients": return `FROM clients WHERE owner_user_id = $1` case "requisitions": return `FROM requisitions WHERE owner_user_id = $1 AND deleted_at IS NULL` default: // applications // Closed applications stay where they are. Moving a rejection from six months ago rewrites history // for no benefit, and it would inflate the count a manager is asked to approve. return `FROM applications WHERE owner_user_id = $1 AND stage NOT IN ('placed','rejected','withdrawn')` } } func countSQL(kind string) string { return `SELECT count(*)::int ` + whereSQL(kind) } // moveOwnership performs the update and returns the ids that actually changed, read back from RETURNING // rather than counted from the request. clients carries owner_since, the other two do not. func moveOwnership(ctx context.Context, tx pgx.Tx, kind string, from, to uuid.UUID) ([]string, error) { var sql string switch kind { case "clients": sql = `UPDATE clients SET owner_user_id = $2, owner_since = now(), updated_at = now() WHERE owner_user_id = $1 RETURNING id` case "requisitions": sql = `UPDATE requisitions SET owner_user_id = $2, updated_at = now() WHERE owner_user_id = $1 AND deleted_at IS NULL RETURNING id` default: sql = `UPDATE applications SET owner_user_id = $2, updated_at = now() WHERE owner_user_id = $1 AND stage NOT IN ('placed','rejected','withdrawn') RETURNING id` } rows, err := tx.Query(ctx, sql, from, to) if err != nil { return nil, translatePG(err) } defer rows.Close() var ids []string for rows.Next() { var id uuid.UUID if e := rows.Scan(&id); e != nil { return nil, e } ids = append(ids, id.String()) } if e := rows.Err(); e != nil { return nil, translatePG(e) } return ids, nil } // auditDeskMove records one event per KIND carrying the full list of ids that moved. // // The granularity is chosen, not accidental. One event per record would be the most literal trail and // would write thousands of rows for a single management action. A summary with counts alone would be // cheap and would make the question "was this particular job order part of that move" unanswerable, which // is the only question anybody asks an audit log months later. Carrying the ids inside three events keeps // the answer exact and the cost bounded. func (s *Service) auditDeskMove(ctx context.Context, tx pgx.Tx, kind string, from, to uuid.UUID, ids []string) error { nv, _ := json.Marshal(map[string]any{ "kind": kind, "from_user_id": from.String(), "owner_user_id": to.String(), "moved_count": len(ids), "moved_ids": ids, }) return s.audit.OnTx(ctx, tx, audit.Event{ Action: "desk.reassigned", ActorUserID: actorPtr(ctx), EntityType: kind, EntityID: from.String(), NewValues: nv, }) }