aravind changes

This commit is contained in:
2026-08-25 16:37:05 +05:30
parent cadea4bd92
commit b6f8655909
27 changed files with 5058 additions and 163 deletions

View 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
}

View 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)
}

View File

@@ -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
}
}

View File

@@ -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",

View File

@@ -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