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 }