aravind changes
This commit is contained in:
@@ -209,7 +209,7 @@ func (s *WorkflowService) Hire(ctx context.Context, ident authctx.Identity,
|
||||
// happened without a log entry, or a log entry for a hire that rolled
|
||||
// back, are both worse than neither.
|
||||
if _, err := repo.New(activity, tx).Insert(ctx, ident, domain.Record{
|
||||
"event_type": "candidate_hired",
|
||||
"event_type": "hire_candidate",
|
||||
"details": fmt.Sprintf("%s was hired", name),
|
||||
"application_id": applicationID,
|
||||
"position_id": app["job_posting_id"],
|
||||
@@ -230,6 +230,11 @@ func (s *WorkflowService) Hire(ctx context.Context, ident authctx.Identity,
|
||||
/* ── Assign ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
// AssignWorker is one worker in an assign request.
|
||||
//
|
||||
// Three ways of naming the application this placement belongs to, in order of
|
||||
// precedence: an id the caller already has, an `application` payload to find or
|
||||
// file one from, and neither — a worker placed straight from the talent pool,
|
||||
// who has no application and is not given an invented one.
|
||||
type AssignWorker struct {
|
||||
WorkerEmail string `json:"worker_email"`
|
||||
WorkerName string `json:"worker_name"`
|
||||
@@ -239,6 +244,17 @@ type AssignWorker struct {
|
||||
WorkerProfileID *string `json:"worker_profile_id"`
|
||||
MatchScore *int `json:"match_score"`
|
||||
Source string `json:"source"`
|
||||
|
||||
// Application is the application to attach this worker to when no
|
||||
// ApplicationID is supplied: the same fields POST /job-applications takes.
|
||||
//
|
||||
// It exists because an application is what puts a person in the pipeline
|
||||
// for a role, and every downstream step keys on it — the candidate record
|
||||
// is addressed by it, and an AI interview takes one as its subject. A
|
||||
// worker assigned without one is unreachable: nothing to open, and nobody
|
||||
// to interview. The frontend was creating it in a second, untransacted
|
||||
// request; this carries it into the same transaction as the assignment.
|
||||
Application *domain.Record `json:"application"`
|
||||
}
|
||||
|
||||
// AssignRequest is the body of POST /job-postings/{id}/assignments.
|
||||
@@ -258,6 +274,11 @@ type AssignResult struct {
|
||||
// request and one transaction. All-or-nothing across the whole batch: assigning
|
||||
// six workers and having the fourth fail should not leave three assigned, three
|
||||
// not, and the caller unsure which.
|
||||
//
|
||||
// Per worker the flow is: settle the application (see settleApplication —
|
||||
// patch the one named, find-or-file the one described, or neither), insert the
|
||||
// assignment linked to whatever that produced, and write the audit entry. The
|
||||
// application comes first because the assignment references it.
|
||||
func (s *WorkflowService) Assign(ctx context.Context, ident authctx.Identity,
|
||||
postingID string, req AssignRequest) (*AssignResult, error) {
|
||||
|
||||
@@ -324,22 +345,43 @@ func (s *WorkflowService) Assign(ctx context.Context, ident authctx.Identity,
|
||||
|
||||
assignRepo := repo.New(assignments, tx)
|
||||
appRepo := repo.New(apps, tx)
|
||||
// The application is filed through the service rather than the
|
||||
// repository so that a payload the caller sent is validated exactly as
|
||||
// POST /job-applications would validate it — unknown fields rejected,
|
||||
// enums checked, required fields demanded — instead of reaching SQL and
|
||||
// coming back as a constraint violation.
|
||||
appSvc := New(apps, tx)
|
||||
activityRepo := repo.New(activity, tx)
|
||||
|
||||
for i, w := range req.Workers {
|
||||
// The application is settled BEFORE the assignment is written,
|
||||
// because the assignment carries the reference to it and a row
|
||||
// cannot point at one that does not exist yet. An empty id means
|
||||
// this worker legitimately has no application.
|
||||
applicationID, err := s.settleApplication(ctx, ident, appRepo, appSvc, postingID, w)
|
||||
if err != nil {
|
||||
return annotate(err, i)
|
||||
}
|
||||
|
||||
record := domain.Record{
|
||||
"job_posting_id": postingID,
|
||||
"worker_email": w.WorkerEmail,
|
||||
"worker_name": w.WorkerName,
|
||||
"starts_at": w.StartsAt,
|
||||
"status": "active",
|
||||
"source": orDefault(w.Source, "manual"),
|
||||
// `owliver` is the column's own default (000001:511) and what
|
||||
// the frontend sends. Substituting `manual` here made the API
|
||||
// and the schema disagree about what an unspecified source
|
||||
// means, so a row written through this endpoint and a row
|
||||
// written through POST /assignments recorded different origins
|
||||
// for the same action.
|
||||
"source": orDefault(w.Source, "owliver"),
|
||||
}
|
||||
if w.EndsAt != nil {
|
||||
record["ends_at"] = *w.EndsAt
|
||||
}
|
||||
if w.ApplicationID != nil {
|
||||
record["application_id"] = *w.ApplicationID
|
||||
if applicationID != "" {
|
||||
record["application_id"] = applicationID
|
||||
}
|
||||
if w.WorkerProfileID != nil {
|
||||
record["worker_profile_id"] = *w.WorkerProfileID
|
||||
@@ -354,29 +396,14 @@ func (s *WorkflowService) Assign(ctx context.Context, ident authctx.Identity,
|
||||
}
|
||||
out.Assignments = append(out.Assignments, created)
|
||||
|
||||
// The application moves to `assigned` only when one was named. A
|
||||
// worker can be placed without having applied — that is what the
|
||||
// talent pool is for — and inventing an application for them would
|
||||
// be worse than leaving the link absent.
|
||||
if w.ApplicationID != nil {
|
||||
updated, err := appRepo.Update(ctx, ident, *w.ApplicationID,
|
||||
domain.Record{"status": "assigned"})
|
||||
if err != nil {
|
||||
return annotate(err, i)
|
||||
}
|
||||
if updated == nil {
|
||||
return domain.NotFound("JobApplication", *w.ApplicationID)
|
||||
}
|
||||
}
|
||||
|
||||
entry := domain.Record{
|
||||
"event_type": "worker_assigned",
|
||||
"event_type": "assign_employee",
|
||||
"details": fmt.Sprintf("%s was assigned", orDefault(w.WorkerName, w.WorkerEmail)),
|
||||
"position_id": postingID,
|
||||
"worker_email": w.WorkerEmail,
|
||||
}
|
||||
if w.ApplicationID != nil {
|
||||
entry["application_id"] = *w.ApplicationID
|
||||
if applicationID != "" {
|
||||
entry["application_id"] = applicationID
|
||||
}
|
||||
if _, err := activityRepo.Insert(ctx, ident, entry); err != nil {
|
||||
return annotate(err, i)
|
||||
@@ -391,6 +418,122 @@ func (s *WorkflowService) Assign(ctx context.Context, ident authctx.Identity,
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// settleApplication resolves the application an assignment belongs to and
|
||||
// returns its id, or "" when this worker has none.
|
||||
//
|
||||
// Three cases, and the middle one is why this exists:
|
||||
//
|
||||
// - an id was supplied — move that application to `assigned`. Unchanged
|
||||
// behaviour, and it still wins over any payload, because an id is a
|
||||
// decision the caller has already made.
|
||||
// - an `application` payload was supplied — find the application on this
|
||||
// posting for this email, and move it to `assigned` if there is one or file
|
||||
// it if there is not. (job_posting_id, email) is UNIQUE
|
||||
// (job_applications_posting_email_key, 000001), so there is at most one to
|
||||
// find and the insert cannot produce a second.
|
||||
// - neither — nothing. A worker placed straight from the talent pool has no
|
||||
// application, and inventing one for them would be worse than leaving the
|
||||
// link absent.
|
||||
//
|
||||
// Everything here runs inside the caller's transaction, which is what makes the
|
||||
// find-or-create safe: a concurrent assign of the same person to the same
|
||||
// posting blocks on the unique index and is then reported as a conflict, rather
|
||||
// than racing past the lookup and writing a duplicate.
|
||||
func (s *WorkflowService) settleApplication(ctx context.Context, ident authctx.Identity,
|
||||
appRepo *repo.Repo, appSvc *Service, postingID string, w AssignWorker) (string, error) {
|
||||
|
||||
if w.ApplicationID != nil {
|
||||
updated, err := appRepo.Update(ctx, ident, *w.ApplicationID,
|
||||
domain.Record{"status": "assigned"})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if updated == nil {
|
||||
return "", domain.NotFound("JobApplication", *w.ApplicationID)
|
||||
}
|
||||
return *w.ApplicationID, nil
|
||||
}
|
||||
if w.Application == nil {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
existing, err := findApplication(ctx, ident, appRepo, postingID, w.WorkerEmail)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if existing != nil {
|
||||
id, _ := existing["id"].(string)
|
||||
updated, err := appRepo.Update(ctx, ident, id, domain.Record{"status": "assigned"})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if updated == nil {
|
||||
return "", domain.NotFound("JobApplication", id)
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// The posting and the person come from the assignment, not from the
|
||||
// payload. A body naming a different posting or a different email would
|
||||
// file an application about somebody other than the worker being placed,
|
||||
// and the lookup above would never find it again.
|
||||
record := domain.Record{}
|
||||
for k, v := range *w.Application {
|
||||
record[k] = v
|
||||
}
|
||||
record["job_posting_id"] = postingID
|
||||
record["email"] = w.WorkerEmail
|
||||
if _, ok := record["applicant_name"]; !ok {
|
||||
record["applicant_name"] = w.WorkerName
|
||||
}
|
||||
if _, ok := record["status"]; !ok {
|
||||
record["status"] = "assigned"
|
||||
}
|
||||
|
||||
created, err := appSvc.Create(ctx, ident, record)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
id, _ := created["id"].(string)
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// findApplication returns this posting's application for this email, or nil.
|
||||
//
|
||||
// The read goes through the repository so it carries the same organization
|
||||
// scope and ownership predicate every other read does — an application in
|
||||
// another tenant is not found, rather than found and then refused. The email
|
||||
// column is citext, so the comparison is case-insensitive: the same equality
|
||||
// the frontend performed with toLowerCase before it had this endpoint.
|
||||
func findApplication(ctx context.Context, ident authctx.Identity, appRepo *repo.Repo,
|
||||
postingID, email string) (domain.Record, error) {
|
||||
|
||||
res := appRepo.Resource()
|
||||
postingCol, ok := res.Column("job_posting_id")
|
||||
if !ok {
|
||||
return nil, domain.Internal(errors.New("service: job_applications has no job_posting_id column"))
|
||||
}
|
||||
emailCol, ok := res.Column("email")
|
||||
if !ok {
|
||||
return nil, domain.Internal(errors.New("service: job_applications has no email column"))
|
||||
}
|
||||
|
||||
page, err := appRepo.List(ctx, ident, domain.ListParams{
|
||||
Limit: 1,
|
||||
Filters: []domain.Filter{
|
||||
{Column: postingCol, Values: []string{postingID}},
|
||||
{Column: emailCol, Values: []string{email}},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(page.Records) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return page.Records[0], nil
|
||||
}
|
||||
|
||||
/* ── Helpers ────────────────────────────────────────────────────────────── */
|
||||
|
||||
// pick takes the caller's value for a key when they supplied a usable one, and
|
||||
|
||||
Reference in New Issue
Block a user