120 lines
4.7 KiB
Go
120 lines
4.7 KiB
Go
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
|
|
}
|