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
|
||||
}
|
||||
Reference in New Issue
Block a user