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

159 lines
5.5 KiB
Go

package httpserver
import (
"net/http"
"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/service"
)
// The multi-record endpoints from api-contract.md §12.1.
//
// These are the first routes that are not a plain CRUD projection of a table,
// and they are shaped as verbs on the record they act on — `.../{id}/hire`,
// `.../{id}/assignments` — rather than as new collections. The action is the
// thing being requested, and it has no independent existence to GET.
//
// AUTHORIZATION REUSES THE POLICY TABLE RATHER THAN ADDING TO IT.
//
// A workflow is exactly as privileged as the writes it performs, so each one
// is gated on the operations it will actually carry out — hire needs UPDATE on
// job-applications and CREATE on staff; assign needs CREATE on assignments and
// UPDATE on job-applications. Inventing a separate `hire` permission would
// create a second place where the answer to "who may do this" lives, and the
// two would eventually disagree. Every pair below resolves to `operators`
// today, which is the intended answer: a talent user cannot hire themselves or
// place themselves on a shift.
// requirement is one (resource, operation) pair a workflow depends on.
type requirement struct {
path string
op domain.Op
}
// authorizeAll refuses unless the caller may perform every listed operation.
//
// All-or-nothing, checked before any transaction opens: a caller who may update
// an application but not create staff must not get halfway through a hire and
// be rolled back. The refusal is the same 403 a single-operation handler gives,
// and names no resource — see domain.Forbidden.
func (s *Server) authorizeAll(w http.ResponseWriter, r *http.Request,
reqs ...requirement) (authctx.Identity, bool) {
ident, err := authctx.MustFrom(r.Context())
if err != nil {
// Unreachable: the middleware refuses an unauthenticated request before
// the router sees it. A missing identity here is a wiring bug.
writeError(w, s.log, domain.Internal(err))
return authctx.Identity{}, false
}
role, known := domain.ParseRole(ident.Role)
if !known {
s.log.Warn("workflow refused: unknown role",
"user_id", ident.UserID, "role", ident.Role, "path", r.URL.Path)
writeError(w, s.log, domain.Forbidden())
return authctx.Identity{}, false
}
for _, req := range reqs {
svc, ok := s.api.Get(req.path)
if !ok {
writeError(w, s.log, domain.Internal(
errUnregisteredResource(req.path)))
return authctx.Identity{}, false
}
if !svc.Resource().Policy.Allows(req.op, role) {
s.log.Warn("workflow authorization refused",
"user_id", ident.UserID, "role", ident.Role,
"required_resource", req.path, "path", r.URL.Path)
writeError(w, s.log, domain.Forbidden())
return authctx.Identity{}, false
}
}
return ident, true
}
type unregisteredResourceError string
func (e unregisteredResourceError) Error() string {
return "httpserver: workflow depends on unregistered resource " + string(e)
}
func errUnregisteredResource(path string) error { return unregisteredResourceError(path) }
func (s *Server) routeWorkflows(mux *http.ServeMux) int {
mux.HandleFunc("POST /api/v1/job-applications/{id}/hire", s.handleHire)
mux.HandleFunc("POST /api/v1/job-postings/{id}/assignments", s.handleAssign)
return 2
}
// handleHire moves an application to `hired` and creates the staff record in
// one transaction. Replaces the two-call sequence at krowHooks.js:302-303.
func (s *Server) handleHire(w http.ResponseWriter, r *http.Request) {
ident, ok := s.authorizeAll(w, r,
requirement{"job-applications", domain.OpUpdate},
requirement{"staff", domain.OpCreate},
requirement{"user-activity", domain.OpCreate},
)
if !ok {
return
}
body, err := decodeBody(r)
if err != nil {
writeError(w, s.log, err)
return
}
result, err := s.workflows.Hire(r.Context(), ident, r.PathValue("id"), body)
if err != nil {
writeError(w, s.log, err)
return
}
s.log.Info("candidate hired", "user_id", ident.UserID,
"application_id", r.PathValue("id"), "staff_id", result.Staff["id"])
// 201: the request created a staff record. The application it also updated
// is returned alongside so the caller can render the new state without a
// second read.
writeJSON(w, http.StatusCreated, envelope{Data: result})
}
// handleAssign places workers on a posting in one transaction. Replaces the 3n
// sequential round-trips at krowHooks.js:421/449/466.
func (s *Server) handleAssign(w http.ResponseWriter, r *http.Request) {
ident, ok := s.authorizeAll(w, r,
requirement{"assignments", domain.OpCreate},
requirement{"job-applications", domain.OpUpdate},
// The workflow may now FILE an application as well as patch one, for a
// worker placed on a posting they never applied to. A write the handler
// performs has to appear in the list it is authorized against, even
// when — as here — the resulting permission set is unchanged.
requirement{"job-applications", domain.OpCreate},
requirement{"user-activity", domain.OpCreate},
)
if !ok {
return
}
var req service.AssignRequest
if err := decodeInto(r, &req); err != nil {
writeError(w, s.log, err)
return
}
result, err := s.workflows.Assign(r.Context(), ident, r.PathValue("id"), req)
if err != nil {
writeError(w, s.log, err)
return
}
s.log.Info("workers assigned", "user_id", ident.UserID,
"job_posting_id", r.PathValue("id"), "count", result.Count)
writeJSON(w, http.StatusCreated, envelope{Data: result})
}