Source identity
| Field | Recorded value |
|---|---|
| Source type | GitHub source |
| Original record | Janady13/missionmatch / internal/modules/recruiting/reassign.go |
| Date | Not specified in source |
| Repository | Janady13/missionmatch |
| Revision | 720d47fae476b0d38477ae4401697d372b1ea2bc |
| Branch | feature/opportunity-to-outcome |
| Path or archive member | internal/modules/recruiting/reassign.go |
| Original SHA-256 | 585340d0009eef52c250b9df26da62a5d2448a0b52e4f5aaa282df282a5f875b |
| Git blob SHA | ebabe34b4d3d21cf827e494fde77ac30b1728a4e |
| Reading-copy SHA-256 | 585340d0009eef52c250b9df26da62a5d2448a0b52e4f5aaa282df282a5f875b |
Recovered source transcript
Download complete source text ↓330 lines · 12.2 KB
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,
})
}