Files
krow_backend/go-api/internal/service/interviews.go
2026-08-25 16:37:05 +05:30

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
}