Source identity
| Field | Recorded value |
|---|---|
| Source type | GitHub source |
| Original record | Janady13/missionmatch / internal/modules/recruiting/activity_service.go |
| Date | Not specified in source |
| Repository | Janady13/missionmatch |
| Revision | 720d47fae476b0d38477ae4401697d372b1ea2bc |
| Branch | feature/opportunity-to-outcome |
| Path or archive member | internal/modules/recruiting/activity_service.go |
| Original SHA-256 | 286b5369172eaddee579ba134e1e340ca91ec33f628e8f23a34fcb1d5a477e9d |
| Git blob SHA | b7ede1ed29673dc9498696e71473500eb3cbc2cb |
| Reading-copy SHA-256 | 286b5369172eaddee579ba134e1e340ca91ec33f628e8f23a34fcb1d5a477e9d |
Every recorded occurrence
These 2 records contain identical original source bytes. Their paths and revisions remain part of the archive.
| Original record | Branch or archive | Revision / identifier | Original location |
|---|---|---|---|
| Janady13/missionmatch / internal/modules/recruiting/activity_service.go | feature/opportunity-to-outcome | 720d47fae476b0d38477ae4401697d372b1ea2bc | Open original |
| Janady13/missionmatch / internal/modules/recruiting/activity_service.go | feature/aioe-upgrade | 2178e7298d0e5d9af83afa12402fd587f0ba6ed8 | Open original |
Recovered source transcript
Download complete source text ↓439 lines · 15.1 KB
package recruiting
import (
"context"
"errors"
"strings"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/shopspring/decimal"
"github.com/missionworkforce/missionmatch/internal/platform/tenant"
)
// The recruiter activity log: what a recruiter actually did, as opposed to what moved as a result.
//
// One rule shapes this whole file. auto_captured is never accepted from a caller. The database trigger
// discards whatever was supplied and sets it from a transaction local flag that only the capture paths
// in this package raise, and LogActivityInput has no such field, so the API cannot express the claim.
// A flag the subject can set about themselves would make every productivity comparison meaningless, and
// that column exists for no other purpose.
var activityKinds = map[string]bool{
"call": true, "email": true, "inmail": true, "text": true,
"meeting": true, "note": true, "submission": true, "screen": true,
}
var activityDirections = map[string]bool{"outbound": true, "inbound": true, "internal": true}
var activityOutcomes = map[string]bool{
"logged": true, "connected": true, "no_answer": true,
"left_message": true, "replied": true, "bounced": true, "declined": true,
}
// connectingOutcomes are the outcomes that represent a human actually reached. The distinction matters
// because a hundred dials and two conversations is a different day from a hundred conversations, and a
// raw activity count reports them identically.
var connectingOutcomes = map[string]bool{"connected": true, "replied": true}
const activitySelect = `ra.id::text, ra.actor_user_id::text, NULLIF(ra.actor_name, ''), ra.kind,
ra.direction, ra.outcome, ra.candidate_id::text, c.full_name, ra.contact_id::text,
ra.requisition_id::text, r.title, ra.application_id::text, ra.body, ra.auto_captured,
ra.occurred_at, ra.created_at`
const activityFrom = `FROM recruiter_activities ra
LEFT JOIN candidates c ON c.organization_id = ra.organization_id AND c.id = ra.candidate_id
LEFT JOIN requisitions r ON r.organization_id = ra.organization_id AND r.id = ra.requisition_id`
func scanActivity(row scannable) (Activity, error) {
var a Activity
var actorID, actorName, candID, candName, contactID, reqID, reqTitle, appID *string
var occurred, created time.Time
if err := row.Scan(&a.ID, &actorID, &actorName, &a.Kind, &a.Direction, &a.Outcome,
&candID, &candName, &contactID, &reqID, &reqTitle, &appID, &a.Body, &a.AutoCaptured,
&occurred, &created); err != nil {
return Activity{}, err
}
a.ActorUserID, a.ActorName = str(actorID), str(actorName)
a.CandidateID, a.CandidateName = str(candID), str(candName)
a.ContactID, a.RequisitionID, a.RequisitionTitle = str(contactID), str(reqID), str(reqTitle)
a.ApplicationID = str(appID)
a.OccurredAt, a.CreatedAt = ts(occurred), ts(created)
return a, nil
}
// LogActivity records an outreach or a note.
//
// occurred_at is accepted so yesterday's calls can be entered this morning, which is how recruiters
// actually work. The database refuses a future date, so backdating is possible and forward dating is
// not, which is the asymmetry that keeps today's number honest.
func (s *Service) LogActivity(ctx context.Context, in LogActivityInput) (Activity, error) {
if s.db == nil {
return Activity{}, ErrNoDatabase
}
if !activityKinds[in.Kind] {
return Activity{}, &ValidationError{Field: "kind", Message: "is not a permitted activity type"}
}
if in.Direction == "" {
in.Direction = "outbound"
}
if !activityDirections[in.Direction] {
return Activity{}, &ValidationError{Field: "direction", Message: "is not a permitted direction"}
}
if in.Outcome == "" {
in.Outcome = "logged"
}
if !activityOutcomes[in.Outcome] {
return Activity{}, &ValidationError{Field: "outcome", Message: "is not a permitted outcome"}
}
body, err := clean("body", in.Body, maxReqDesc)
if err != nil {
return Activity{}, err
}
candID, err := optUUID("candidate_id", in.CandidateID)
if err != nil {
return Activity{}, err
}
contactID, err := optUUID("contact_id", in.ContactID)
if err != nil {
return Activity{}, err
}
reqID, err := optUUID("requisition_id", in.RequisitionID)
if err != nil {
return Activity{}, err
}
appID, err := optUUID("application_id", in.ApplicationID)
if err != nil {
return Activity{}, err
}
// An activity about nothing is noise, and the database says so too. Asking here names the problem.
if candID == nil && contactID == nil && reqID == nil && appID == nil {
return Activity{}, &ValidationError{Field: "candidate_id",
Message: "say who or what this was about"}
}
occurred, err := optTime("occurred_at", in.OccurredAt)
if err != nil {
return Activity{}, err
}
actor := actorPtr(ctx)
if actor == nil {
return Activity{}, tenant.ErrNoTenant
}
var id string
err = s.db.RunInTenantTx(ctx, func(ctx context.Context, tx pgx.Tx) error {
return insertActivity(ctx, tx, activityRow{
actor: actor, kind: in.Kind, direction: in.Direction, outcome: in.Outcome,
candidateID: candID, contactID: contactID, requisitionID: reqID, applicationID: appID,
body: body, occurredAt: occurred,
}, &id)
})
if err != nil {
return Activity{}, err
}
return s.activityRow(ctx, id)
}
// activityRow is the insert shape, shared by the human path and the capture paths.
type activityRow struct {
actor *uuid.UUID
kind string
direction string
outcome string
candidateID *uuid.UUID
contactID *uuid.UUID
requisitionID *uuid.UUID
applicationID *uuid.UUID
body string
occurredAt *time.Time
}
// insertActivity writes the row. actor_name is resolved at write time so the history stays legible after
// the person is deleted and actor_user_id is nulled.
func insertActivity(ctx context.Context, tx pgx.Tx, r activityRow, outID *string) error {
var id string
err := tx.QueryRow(ctx, `
INSERT INTO recruiter_activities
(organization_id, actor_user_id, actor_name, kind, direction, outcome,
candidate_id, contact_id, requisition_id, application_id, body, occurred_at)
VALUES (NULLIF(current_setting('app.current_org_id', true), '')::uuid,
$1, COALESCE((SELECT full_name FROM users WHERE id = $1), ''),
$2, $3, $4, $5, $6, $7, $8, $9, COALESCE($10, now()))
RETURNING id::text`,
r.actor, r.kind, r.direction, r.outcome,
r.candidateID, r.contactID, r.requisitionID, r.applicationID, r.body, r.occurredAt).Scan(&id)
if err != nil {
return translateActivityErr(err)
}
if outID != nil {
*outID = id
}
return nil
}
// CaptureActivity records something the platform did rather than something a human typed.
//
// The flag is raised transaction locally, the insert happens, and the flag is cleared, so nothing else
// in the same transaction is accidentally marked as captured. This is the ONLY path that can produce a
// captured row, and it is not reachable from HTTP.
func captureActivity(ctx context.Context, tx pgx.Tx, r activityRow) error {
if _, err := tx.Exec(ctx, `SELECT set_config('app.rm_system_activity', '1', true)`); err != nil {
return err
}
insertErr := insertActivity(ctx, tx, r, nil)
if _, err := tx.Exec(ctx, `SELECT set_config('app.rm_system_activity', '', true)`); err != nil {
return err
}
return insertErr
}
// CorrectActivityNote fixes a typo. Nothing else about a logged activity can change, enforced by the
// database, because everything else is an input to a metric.
func (s *Service) CorrectActivityNote(ctx context.Context, id string, body string) (Activity, error) {
if s.db == nil {
return Activity{}, ErrNoDatabase
}
clean, err := clean("body", body, maxReqDesc)
if err != nil {
return Activity{}, err
}
err = s.db.RunInTenantTx(ctx, func(ctx context.Context, tx pgx.Tx) error {
tag, e := tx.Exec(ctx,
`UPDATE recruiter_activities SET body = $2 WHERE id = $1::bigint`, id, clean)
if e != nil {
return translateActivityErr(e)
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return nil
})
if err != nil {
return Activity{}, err
}
return s.activityRow(ctx, id)
}
func (s *Service) activityRow(ctx context.Context, id string) (Activity, error) {
var out Activity
err := s.db.RunInTenantTx(ctx, func(ctx context.Context, tx pgx.Tx) error {
a, e := scanActivity(tx.QueryRow(ctx,
`SELECT `+activitySelect+` `+activityFrom+` WHERE ra.id = $1::bigint`, id))
if e != nil {
if errors.Is(e, pgx.ErrNoRows) {
return ErrNotFound
}
return translatePG(e)
}
out = a
return nil
})
return out, err
}
// ListActivities returns the feed, newest first, filtered server side.
func (s *Service) ListActivities(ctx context.Context, f ActivityFilter, limit, offset int) (ActivityList, error) {
if s.db == nil {
return ActivityList{}, ErrNoDatabase
}
if limit <= 0 || limit > 200 {
limit = 50
}
if offset < 0 {
offset = 0
}
where := []string{"true"}
var args []any
add := func(clause string, v any) {
args = append(args, v)
where = append(where, strings.ReplaceAll(clause, "?", "$"+itoa(len(args))))
}
switch f.Actor {
case "":
case "me":
actor := actorPtr(ctx)
if actor == nil {
return ActivityList{}, tenant.ErrNoTenant
}
add("ra.actor_user_id = ?", *actor)
default:
id, err := uuid.Parse(f.Actor)
if err != nil {
return ActivityList{}, &ValidationError{Field: "actor", Message: "must be me or a user id"}
}
add("ra.actor_user_id = ?", id)
}
for field, raw := range map[string]string{
"candidate_id": f.CandidateID, "requisition_id": f.RequisitionID, "application_id": f.ApplicationID,
} {
if raw == "" {
continue
}
id, err := uuid.Parse(raw)
if err != nil {
return ActivityList{}, &ValidationError{Field: field, Message: "must be an id"}
}
add("ra."+field+" = ?", id)
}
if f.Kind != "" {
if !activityKinds[f.Kind] {
return ActivityList{}, &ValidationError{Field: "kind", Message: "is not a permitted activity type"}
}
add("ra.kind = ?", f.Kind)
}
// Captured rows are excluded by default. A recruiter looking at their own log means what they did,
// and mixing in every screen the platform ran on their behalf would drown it.
if !f.IncludeCaptured {
where = append(where, "NOT ra.auto_captured")
}
since, err := optTime("since", f.Since)
if err != nil {
return ActivityList{}, err
}
if since != nil {
add("ra.occurred_at >= ?", *since)
}
cond := strings.Join(where, " AND ")
out := ActivityList{Activities: []Activity{}, Limit: limit, Offset: offset}
err = s.db.RunInTenantTx(ctx, func(ctx context.Context, tx pgx.Tx) error {
if e := tx.QueryRow(ctx, `SELECT count(*) `+activityFrom+` WHERE `+cond, args...).Scan(&out.Total); e != nil {
return translatePG(e)
}
rows, e := tx.Query(ctx, `SELECT `+activitySelect+` `+activityFrom+` WHERE `+cond+`
ORDER BY ra.occurred_at DESC, ra.id DESC
LIMIT $`+itoa(len(args)+1)+` OFFSET $`+itoa(len(args)+2), append(args, limit, offset)...)
if e != nil {
return translatePG(e)
}
defer rows.Close()
for rows.Next() {
a, e := scanActivity(rows)
if e != nil {
return translatePG(e)
}
out.Activities = append(out.Activities, a)
}
return rows.Err()
})
if err != nil {
return ActivityList{}, err
}
return out, nil
}
// ActivitySummary reports effort over a window, per recruiter.
//
// Two numbers per person, deliberately. Total is everything logged; Connects is the subset where a human
// was actually reached. A hundred dials with two conversations and a hundred conversations are different
// days, and a raw activity count reports them identically, which is how activity metrics come to be
// gamed and then distrusted.
//
// Captured rows are counted separately and never pooled with logged effort.
func (s *Service) ActivitySummary(ctx context.Context, sinceRaw string) (ActivitySummaryView, error) {
if s.db == nil {
return ActivitySummaryView{}, ErrNoDatabase
}
since, err := optTime("since", sinceRaw)
if err != nil {
return ActivitySummaryView{}, err
}
if since == nil {
t := time.Now().UTC().AddDate(0, 0, -7)
since = &t
}
out := ActivitySummaryView{Since: ts(*since), Recruiters: []RecruiterEffort{}, ByKind: []KindCount{}}
err = s.db.RunInTenantTx(ctx, func(ctx context.Context, tx pgx.Tx) error {
rows, e := tx.Query(ctx, `
SELECT COALESCE(ra.actor_user_id::text, ''),
COALESCE(NULLIF(ra.actor_name, ''), 'Removed user'),
count(*) FILTER (WHERE NOT ra.auto_captured),
count(*) FILTER (WHERE NOT ra.auto_captured AND ra.outcome IN ('connected','replied')),
count(*) FILTER (WHERE ra.auto_captured)
FROM recruiter_activities ra
WHERE ra.occurred_at >= $1
GROUP BY ra.actor_user_id, ra.actor_name
ORDER BY 3 DESC, 2 ASC`, *since)
if e != nil {
return translatePG(e)
}
defer rows.Close()
for rows.Next() {
var r RecruiterEffort
if e := rows.Scan(&r.UserID, &r.Name, &r.Logged, &r.Connects, &r.Captured); e != nil {
return translatePG(e)
}
out.TotalLogged += r.Logged
out.TotalConnects += r.Connects
out.TotalCaptured += r.Captured
out.Recruiters = append(out.Recruiters, r)
}
if e := rows.Err(); e != nil {
return e
}
kinds, e := tx.Query(ctx, `
SELECT ra.kind, count(*) FROM recruiter_activities ra
WHERE ra.occurred_at >= $1 AND NOT ra.auto_captured
GROUP BY ra.kind ORDER BY 2 DESC, 1 ASC`, *since)
if e != nil {
return translatePG(e)
}
defer kinds.Close()
for kinds.Next() {
var k KindCount
if e := kinds.Scan(&k.Kind, &k.Count); e != nil {
return translatePG(e)
}
out.ByKind = append(out.ByKind, k)
}
return kinds.Err()
})
if err != nil {
return ActivitySummaryView{}, err
}
// Connect rate is computed here rather than in SQL so the division is exact decimal and guarded, and
// so a zero denominator yields an absent rate rather than a claimed zero percent.
for i := range out.Recruiters {
out.Recruiters[i].ConnectRate = rateOf(out.Recruiters[i].Connects, out.Recruiters[i].Logged)
}
out.ConnectRate = rateOf(out.TotalConnects, out.TotalLogged)
return out, nil
}
// rateOf is a share to two places, exact, and empty when the denominator is zero. A connect rate of
// "0.00" on nobody dialling claims a real failure; an absent rate says there is nothing to report.
func rateOf(part, total int) string {
if total <= 0 {
return ""
}
return decimal.NewFromInt(int64(part)).
Mul(decimal.NewFromInt(100)).
Div(decimal.NewFromInt(int64(total))).
StringFixed(2)
}
func translateActivityErr(err error) error {
var pg *pgconn.PgError
if !errors.As(err, &pg) {
return translatePG(err)
}
switch {
case pg.Code == "22007" && strings.Contains(pg.Message, "in the future"):
return &ValidationError{Field: "occurred_at", Message: "an activity cannot be logged in the future"}
case pg.Code == "22007" && strings.Contains(pg.Message, "mistyped"):
return &ValidationError{Field: "occurred_at", Message: "that date looks mistyped"}
case pg.Code == "42501" && strings.Contains(pg.Message, "only its note"):
return &ValidationError{Field: "body", Message: "a logged activity is a record: only its note may be corrected"}
case pg.Code == "42501":
return &ValidationError{Field: "id", Message: "activity history cannot be deleted"}
case pg.Code == "23514" && strings.Contains(pg.ConstraintName, "subject_chk"):
return &ValidationError{Field: "candidate_id", Message: "say who or what this was about"}
}
return translatePG(err)
}