aravind changes
This commit is contained in:
119
go-api/internal/service/interviews.go
Normal file
119
go-api/internal/service/interviews.go
Normal file
@@ -0,0 +1,119 @@
|
||||
package service
|
||||
|
||||
// Completing an AI interview, in one transaction.
|
||||
//
|
||||
// THE PROBLEM THIS SOLVES
|
||||
//
|
||||
// Finishing an interview is two writes: the interview record, and the
|
||||
// application it was for — which has to carry the verdict forward as
|
||||
// `status: interview`, `interview_id` and the score, because that is what the
|
||||
// funnel and the analytics read. The frontend performed them as two independent
|
||||
// requests (AIInterviewModal.finishInterview), and that had two consequences.
|
||||
//
|
||||
// The first is authorization. `ai-interviews:Create` is open to everyone and
|
||||
// `job-applications:Update` is operators only, so a talent user sitting their
|
||||
// own interview — which is the whole talent flow — got a 201 for the interview
|
||||
// and a 403 for the link. The interview existed, the application still said
|
||||
// `applied`, `interview_id` was never set, and every consumer that counts
|
||||
// `status === 'interview' || interview_id` could not see it.
|
||||
//
|
||||
// The second is atomicity: even for an operator, a failure between the two left
|
||||
// an interview attached to an application that did not know about it.
|
||||
//
|
||||
// WHY THE SERVER MAY WRITE WHAT THE CALLER MAY NOT
|
||||
//
|
||||
// The link is not a widening of `job-applications:Update`. A talent caller
|
||||
// still cannot PATCH an application — the policy table is unchanged, and the
|
||||
// role gate on that route still refuses them. What happens here is that the
|
||||
// server updates the one row the interview it just wrote already names, and
|
||||
// only after repo.guardInsert has proved that row belongs to the caller: a
|
||||
// talent caller creating an interview for an application that is not theirs is
|
||||
// answered 404 before anything is written. That guard is exactly the ownership
|
||||
// proof this update needs.
|
||||
//
|
||||
// The alternative — adding `talent` to `job-applications:Update` with a
|
||||
// per-column allowlist — was rejected in the plan for the reason the workflows
|
||||
// file header gives: it would put a second authorization mechanism beside the
|
||||
// per-operation one, and the two would eventually disagree.
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
|
||||
"github.com/krow/krow-backend/go-api/internal/authctx"
|
||||
"github.com/krow/krow-backend/go-api/internal/domain"
|
||||
"github.com/krow/krow-backend/go-api/internal/repo"
|
||||
)
|
||||
|
||||
// InterviewsPath is the resource whose Create is routed through here.
|
||||
// Exported so the HTTP layer names the same resource this file special-cases,
|
||||
// rather than repeating a string literal that could drift.
|
||||
const InterviewsPath = "ai-interviews"
|
||||
|
||||
// CreateInterview inserts an interview and links its application, atomically.
|
||||
//
|
||||
// The response is the interview record, unchanged: POST /api/v1/ai-interviews
|
||||
// answered 201 with the created interview before this existed and answers 201
|
||||
// with the created interview now. The application update is a consequence of
|
||||
// the request, not a second thing in it.
|
||||
func (s *WorkflowService) CreateInterview(ctx context.Context, ident authctx.Identity,
|
||||
body domain.Record) (domain.Record, error) {
|
||||
|
||||
interviews, err := resourceByPath(InterviewsPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
apps, err := resourceByPath("job-applications")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var out domain.Record
|
||||
err = s.inTx(ctx, func(tx pgx.Tx) error {
|
||||
// Through the resource's own service over the transaction, so the body
|
||||
// is validated and the ownership guard runs exactly as they do on the
|
||||
// plain create path. Nothing about the interview itself changes here.
|
||||
created, err := New(interviews, tx).Create(ctx, ident, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out = created
|
||||
|
||||
applicationID, _ := created["application_id"].(string)
|
||||
if applicationID == "" {
|
||||
// Unreachable: application_id is NOT NULL and Required, so the
|
||||
// create above would have refused. Checked rather than assumed
|
||||
// because the alternative is an Update against an empty id.
|
||||
return nil
|
||||
}
|
||||
|
||||
patch := domain.Record{
|
||||
"status": "interview",
|
||||
"interview_id": created["id"],
|
||||
}
|
||||
// The score moves onto the application only when the caller said
|
||||
// something about it. Copying the column unconditionally would write
|
||||
// the interview's default 0 over a real screening score, which is a
|
||||
// loss caused by a field the request never mentioned.
|
||||
if _, said := body["overall_interview_score"]; said {
|
||||
patch["ai_score"] = created["overall_interview_score"]
|
||||
}
|
||||
|
||||
updated, err := repo.New(apps, tx).Update(ctx, ident, applicationID, patch)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if updated == nil {
|
||||
// Unreachable for the same reason the guard above passed: the row
|
||||
// is in this organization and, for a talent caller, theirs. Kept so
|
||||
// a silent no-op cannot pass for a completed interview.
|
||||
return domain.NotFound(apps.Name, applicationID)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
103
go-api/internal/service/owliver.go
Normal file
103
go-api/internal/service/owliver.go
Normal file
@@ -0,0 +1,103 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/krow/krow-backend/go-api/internal/authctx"
|
||||
"github.com/krow/krow-backend/go-api/internal/definition"
|
||||
"github.com/krow/krow-backend/go-api/internal/domain"
|
||||
"github.com/krow/krow-backend/go-api/internal/owliver"
|
||||
)
|
||||
|
||||
// allowedSuggestionParams names the accepted query parameters, on the pattern
|
||||
// of allowedDefinitionFilters: anything else is a caller mistake worth saying
|
||||
// out loud rather than a filter to ignore. It also keeps the endpoint from
|
||||
// quietly accepting a `role`, `org` or `user` parameter should one ever be
|
||||
// added by a client — who is asking is read from the session and nowhere else.
|
||||
var allowedSuggestionParams = map[string]bool{
|
||||
"page": true,
|
||||
"query": true,
|
||||
}
|
||||
|
||||
// maxEchoedPage bounds how much of a rejected page value is quoted back. Long
|
||||
// enough to name every real surface, short enough that the error cannot be used
|
||||
// to reflect a payload.
|
||||
const maxEchoedPage = 64
|
||||
|
||||
// SuggestionQuery is a validated suggestion request.
|
||||
//
|
||||
// Page is canonical: aliases are resolved here so nothing downstream has to
|
||||
// know that `hired` and `hired-history` are the same surface.
|
||||
type SuggestionQuery struct {
|
||||
Page string
|
||||
Query string
|
||||
}
|
||||
|
||||
// SuggestionsService answers "what could I usefully ask on this page?".
|
||||
//
|
||||
// It holds no pool, opens no transaction and reads no table. That is not an
|
||||
// omission — the panel calls it while the user types, and everything it needs
|
||||
// is the static catalogue in internal/owliver plus the caller's role. It is a
|
||||
// service rather than a function in the handler so that validation and
|
||||
// authorization sit where every other endpoint's do.
|
||||
type SuggestionsService struct{}
|
||||
|
||||
// NewSuggestions builds the suggestion service.
|
||||
func NewSuggestions() *SuggestionsService { return &SuggestionsService{} }
|
||||
|
||||
// ParseParams validates the query string.
|
||||
//
|
||||
// `page` is required and must name a real surface — the same closed vocabulary
|
||||
// internal/definition validates a definition's `pages:` against, so there is
|
||||
// one answer to "is that a page" in this process. `query` is optional: an
|
||||
// absent or too-short one is not an error, it is a request that has nothing to
|
||||
// rank yet, and Suggest answers it with an empty list.
|
||||
func (s *SuggestionsService) ParseParams(q url.Values) (SuggestionQuery, error) {
|
||||
var out SuggestionQuery
|
||||
|
||||
for name := range q {
|
||||
if !allowedSuggestionParams[name] {
|
||||
return out, domain.Invalid(fmt.Sprintf("unknown parameter %q", name))
|
||||
}
|
||||
}
|
||||
|
||||
raw := strings.TrimSpace(q.Get("page"))
|
||||
if raw == "" {
|
||||
return out, domain.Invalid("page is required")
|
||||
}
|
||||
page := definition.CanonicalPage(raw)
|
||||
if page == "" {
|
||||
echoed := raw
|
||||
if len(echoed) > maxEchoedPage {
|
||||
echoed = echoed[:maxEchoedPage]
|
||||
}
|
||||
return out, domain.Invalid(fmt.Sprintf(
|
||||
"Unsupported page: %s. Supported pages: %s.",
|
||||
echoed, strings.Join(definition.SupportedPages, ", ")))
|
||||
}
|
||||
|
||||
out.Page = page
|
||||
out.Query = q.Get("query")
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Suggest ranks the page's readings for this caller.
|
||||
//
|
||||
// The role comes off the session-resolved identity, exactly as Server.authorize
|
||||
// reads it, and an unrecognised role is offered nothing — the same deny-by-
|
||||
// default the policy table applies. Nothing else about the caller is consulted:
|
||||
// there is no branch here on organization, account type or anything a request
|
||||
// could set.
|
||||
//
|
||||
// No error case beyond parsing. A page with no readings for this caller, and a
|
||||
// query that matches none of them, both answer with an empty list — an empty
|
||||
// result is an answer, not a failure.
|
||||
func (s *SuggestionsService) Suggest(ident authctx.Identity, q SuggestionQuery) []owliver.Suggestion {
|
||||
role, known := domain.ParseRole(ident.Role)
|
||||
if !known {
|
||||
return []owliver.Suggestion{}
|
||||
}
|
||||
return owliver.Suggest(q.Page, q.Query, role)
|
||||
}
|
||||
@@ -150,7 +150,7 @@ func (s *Service) Get(ctx context.Context, ident authctx.Identity, id string) (d
|
||||
|
||||
// Create validates and inserts, returning the complete stored record.
|
||||
func (s *Service) Create(ctx context.Context, ident authctx.Identity, body domain.Record) (domain.Record, error) {
|
||||
clean, err := s.validate(body, true)
|
||||
clean, err := s.validate(ident, body, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -162,7 +162,7 @@ func (s *Service) Update(ctx context.Context, ident authctx.Identity, id string,
|
||||
if !isUUID(id) {
|
||||
return nil, domain.NotFound(s.res.Name, id)
|
||||
}
|
||||
clean, err := s.validate(patch, false)
|
||||
clean, err := s.validate(ident, patch, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -201,7 +201,11 @@ func (s *Service) Delete(ctx context.Context, ident authctx.Identity, id string)
|
||||
// exactly how `interview_id`, `training_outline` and `score_breakdown` would
|
||||
// have been lost: the frontend would have written them, the API would have
|
||||
// accepted the request, and the data would never have arrived.
|
||||
func (s *Service) validate(in domain.Record, isCreate bool) (domain.Record, error) {
|
||||
//
|
||||
// The identity is a parameter because "the server will supply this column"
|
||||
// is not a property of the column alone: a talent-only derivation supplies it
|
||||
// for a talent caller and for nobody else. See serverSupplies.
|
||||
func (s *Service) validate(ident authctx.Identity, in domain.Record, isCreate bool) (domain.Record, error) {
|
||||
details := map[string]string{}
|
||||
out := make(domain.Record, len(in))
|
||||
|
||||
@@ -237,7 +241,7 @@ func (s *Service) validate(in domain.Record, isCreate bool) (domain.Record, erro
|
||||
if !col.Required {
|
||||
continue
|
||||
}
|
||||
if s.serverSupplies(col.Name) {
|
||||
if s.serverSupplies(col.Name, ident) {
|
||||
// The repository fills this from the session, so demanding it
|
||||
// from the caller would reject a request the server is about to
|
||||
// complete correctly. evidence.worker_email is the live case.
|
||||
@@ -262,13 +266,24 @@ func (s *Service) validate(in domain.Record, isCreate bool) (domain.Record, erro
|
||||
}
|
||||
|
||||
// serverSupplies reports whether a column is filled in from the authenticated
|
||||
// session rather than from the request body.
|
||||
func (s *Service) serverSupplies(name string) bool {
|
||||
// session rather than from the request body, FOR THIS CALLER.
|
||||
//
|
||||
// The caller matters. A TalentOnly derivation records who the row is ABOUT, and
|
||||
// the repository fills it for a talent caller only — when an operator files an
|
||||
// application or logs evidence on somebody else's behalf, the subject is not
|
||||
// the operator, so nothing is derived and the value has to come from the body.
|
||||
// Treating those columns as server-supplied for every role was how an operator
|
||||
// creating a job application without an email got as far as SQL and came back
|
||||
// with a not-null violation instead of the required-field message the contract
|
||||
// promises. It must agree with repo.derivedValues, which decides the same thing
|
||||
// on the write path.
|
||||
func (s *Service) serverSupplies(name string, ident authctx.Identity) bool {
|
||||
if s.res.Policy == nil {
|
||||
return false
|
||||
}
|
||||
isTalent := ident.Role == string(domain.RoleTalent)
|
||||
for _, d := range s.res.Policy.Derived {
|
||||
if d.Column == name {
|
||||
if d.Column == name && (!d.TalentOnly || isTalent) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"github.com/krow/krow-backend/go-api/internal/authctx"
|
||||
"github.com/krow/krow-backend/go-api/internal/domain"
|
||||
)
|
||||
|
||||
// The two callers validation distinguishes. Only the role is read.
|
||||
var (
|
||||
asOperator = authctx.Identity{Role: string(domain.RoleAdmin)}
|
||||
asTalent = authctx.Identity{Role: string(domain.RoleTalent)}
|
||||
)
|
||||
|
||||
// These exercise query parsing and validation without a database, so the
|
||||
// contract's defaults are pinned even when PostgreSQL is not available.
|
||||
|
||||
@@ -139,24 +147,24 @@ func TestReservedParametersAreNotFilters(t *testing.T) {
|
||||
func TestValidateRequiredAndUnknownAndEnum(t *testing.T) {
|
||||
svc := New(resource(t, "job-postings"), nil)
|
||||
|
||||
if _, err := svc.validate(domain.Record{}, true); err == nil {
|
||||
if _, err := svc.validate(asOperator, domain.Record{}, true); err == nil {
|
||||
t.Error("a create with no title was accepted")
|
||||
}
|
||||
if _, err := svc.validate(domain.Record{"title": " "}, true); err == nil {
|
||||
if _, err := svc.validate(asOperator, domain.Record{"title": " "}, true); err == nil {
|
||||
t.Error("a blank title was accepted")
|
||||
}
|
||||
if _, err := svc.validate(domain.Record{"title": "X", "bogus": 1}, true); err == nil {
|
||||
if _, err := svc.validate(asOperator, domain.Record{"title": "X", "bogus": 1}, true); err == nil {
|
||||
t.Error("an unknown field was accepted")
|
||||
}
|
||||
if _, err := svc.validate(domain.Record{"title": "X", "status": "archived"}, true); err == nil {
|
||||
if _, err := svc.validate(asOperator, domain.Record{"title": "X", "status": "archived"}, true); err == nil {
|
||||
t.Error("an invalid enum value was accepted")
|
||||
}
|
||||
if _, err := svc.validate(domain.Record{"title": "X", "status": "active"}, true); err != nil {
|
||||
if _, err := svc.validate(asOperator, domain.Record{"title": "X", "status": "active"}, true); err != nil {
|
||||
t.Errorf("a valid payload was rejected: %v", err)
|
||||
}
|
||||
|
||||
// Server-owned fields are stripped, not rejected.
|
||||
out, err := svc.validate(domain.Record{"title": "X", "id": "abc", "org_id": "def"}, true)
|
||||
out, err := svc.validate(asOperator, domain.Record{"title": "X", "id": "abc", "org_id": "def"}, true)
|
||||
if err != nil {
|
||||
t.Fatalf("server-owned fields caused a rejection: %v", err)
|
||||
}
|
||||
@@ -168,11 +176,62 @@ func TestValidateRequiredAndUnknownAndEnum(t *testing.T) {
|
||||
}
|
||||
|
||||
// An update needs no required fields — it is a partial by definition.
|
||||
if _, err := svc.validate(domain.Record{"location": "Here"}, false); err != nil {
|
||||
if _, err := svc.validate(asOperator, domain.Record{"location": "Here"}, false); err != nil {
|
||||
t.Errorf("a partial update was rejected: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A talent-only derivation is only server-supplied for a talent caller.
|
||||
//
|
||||
// The column records who the row is ABOUT, and the repository fills it from the
|
||||
// session for talent and for nobody else (repo.derivedValues). Validation has
|
||||
// to agree: an operator filing an application for somebody else must be told
|
||||
// `email` is required, rather than being let through to a not-null violation
|
||||
// from SQL — and a talent caller must not be asked for the value the server is
|
||||
// about to override anyway.
|
||||
func TestValidateHonoursTalentOnlyDerivation(t *testing.T) {
|
||||
apps := New(resource(t, "job-applications"), nil)
|
||||
body := domain.Record{
|
||||
"job_posting_id": "00000000-0000-0000-0000-000000000000",
|
||||
"applicant_name": "Someone",
|
||||
}
|
||||
|
||||
if _, err := apps.validate(asTalent, body, true); err != nil {
|
||||
t.Errorf("a talent create without email was rejected: %v", err)
|
||||
}
|
||||
|
||||
_, err := apps.validate(asOperator, body, true)
|
||||
if err == nil {
|
||||
t.Fatal("an operator create without email was accepted")
|
||||
}
|
||||
var apiErr *domain.Error
|
||||
if !errors.As(err, &apiErr) {
|
||||
t.Fatalf("error is not an API error: %v", err)
|
||||
}
|
||||
if apiErr.Details["email"] != "required" {
|
||||
t.Errorf("details = %v, want email: required", apiErr.Details)
|
||||
}
|
||||
|
||||
// evidence.worker_email is the same shape, and the case the original
|
||||
// comment in serverSupplies was written for.
|
||||
ev := New(resource(t, "evidence"), nil)
|
||||
if _, err := ev.validate(asTalent, domain.Record{"type": "photo_identify"}, true); err != nil {
|
||||
t.Errorf("a talent evidence create without worker_email was rejected: %v", err)
|
||||
}
|
||||
if _, err := ev.validate(asOperator, domain.Record{"type": "photo_identify"}, true); err == nil {
|
||||
t.Error("an operator evidence create without worker_email was accepted")
|
||||
}
|
||||
|
||||
// A derivation that is NOT talent-only stays server-supplied for everyone:
|
||||
// user_activity records who acted, whoever that is.
|
||||
act := New(resource(t, "user-activity"), nil)
|
||||
for name, ident := range map[string]authctx.Identity{"operator": asOperator, "talent": asTalent} {
|
||||
if _, err := act.validate(ident, domain.Record{"event_type": "x"}, true); err != nil {
|
||||
t.Errorf("%s: an activity create was rejected: %v", name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsUUID(t *testing.T) {
|
||||
valid := []string{
|
||||
"00000000-0000-0000-0000-000000000000",
|
||||
|
||||
@@ -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