// Package service sits between the HTTP layer and the repositories. // // It owns request validation, organization scoping, and the three behaviours // the contract is most specific about: what a missing record does on read // (§5.1), what a missing record does on delete (§12.7), and what a PATCH is // allowed to touch (§3.2). // // No business rule lives here that the frontend does not already impose. The // scoring, funnel and matching logic all stay client-side in Phase 2C. package service import ( "context" "fmt" "net/url" "sort" "strconv" "strings" "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" ) // MaxLimit caps how much a single request can ask for. Nothing in the frontend // asks for more than 500; this exists so a hand-written query cannot ask for // everything. See api-contract.md §8. const MaxLimit = 1000 // Service serves one resource. type Service struct { res *domain.Resource db repo.Querier } // New builds a service for a resource. func New(res *domain.Resource, db repo.Querier) *Service { return &Service{res: res, db: db} } // Resource is the descriptor this service serves. func (s *Service) Resource() *domain.Resource { return s.res } func (s *Service) repo() *repo.Repo { return repo.New(s.res, s.db) } /* ── Query parsing ──────────────────────────────────────────────────────── */ // Reserved query parameters. Every other parameter is a field filter. // No column in any resource collides with these. See api-contract.md §1. var reserved = map[string]bool{"sort": true, "limit": true, "offset": true} // ParseList turns a query string into validated list parameters, applying this // resource's own defaults. The defaults are not generic: each one is the // literal argument at the frontend call site (api-contract.md §8.1). func (s *Service) ParseList(q url.Values) (domain.ListParams, error) { p := domain.ListParams{Limit: s.res.DefaultLimit} sortSpec := s.res.DefaultSort if raw, ok := q["sort"]; ok && len(raw) > 0 { sortSpec = raw[0] // an explicitly empty ?sort= means "no ordering" } if sortSpec != "" { field := sortSpec if strings.HasPrefix(field, "-") { p.Desc, field = true, field[1:] } if !s.res.Sortable(field) { return p, domain.Invalid(fmt.Sprintf("cannot sort by %q on %s", field, s.res.Name)) } p.Sort = field } if raw := q.Get("limit"); raw != "" { n, err := strconv.Atoi(raw) if err != nil || n < 0 { return p, domain.Invalid("limit must be a non-negative integer") } if n > MaxLimit { n = MaxLimit } p.Limit = n } if raw := q.Get("offset"); raw != "" { n, err := strconv.Atoi(raw) if err != nil || n < 0 { return p, domain.Invalid("offset must be a non-negative integer") } p.Offset = n } // Deterministic filter order keeps generated SQL stable and cacheable. names := make([]string, 0, len(q)) for name := range q { if !reserved[name] { names = append(names, name) } } sort.Strings(names) for _, name := range names { col, ok := s.res.Column(name) if !ok { return p, domain.Invalid(fmt.Sprintf("unknown filter field %q on %s", name, s.res.Name)) } if !s.res.Filterable(name) { return p, domain.Invalid(fmt.Sprintf( "%s is not filterable: array and JSON columns cannot be compared for equality", name)) } values := q[name] if len(values) == 0 { continue } p.Filters = append(p.Filters, domain.Filter{Column: col, Values: values}) } return p, nil } /* ── Reads ──────────────────────────────────────────────────────────────── */ // List returns a page. An empty result is a page with no records, never an error. func (s *Service) List(ctx context.Context, ident authctx.Identity, p domain.ListParams) (*domain.Page, error) { page, err := s.repo().List(ctx, ident, p) if err != nil { return nil, err } if page.Records == nil { page.Records = []domain.Record{} } return page, nil } // Get returns one record, or a not_found error carrying store.js's message. func (s *Service) Get(ctx context.Context, ident authctx.Identity, id string) (domain.Record, error) { if !isUUID(id) { // store.js throws " not found" for any id it cannot find, // and a malformed id is simply an id it cannot find. return nil, domain.NotFound(s.res.Name, id) } rec, err := s.repo().Get(ctx, ident, id) if err != nil { return nil, err } if rec == nil { return nil, domain.NotFound(s.res.Name, id) } return rec, nil } /* ── Writes ─────────────────────────────────────────────────────────────── */ // Create validates and inserts, returning the complete stored record. func (s *Service) Create(ctx context.Context, ident authctx.Identity, body domain.Record) (domain.Record, error) { clean, err := s.validate(body, true) if err != nil { return nil, err } return s.repo().Insert(ctx, ident, clean) } // Update shallow-merges the supplied fields. Absent keys are left untouched. func (s *Service) Update(ctx context.Context, ident authctx.Identity, id string, patch domain.Record) (domain.Record, error) { if !isUUID(id) { return nil, domain.NotFound(s.res.Name, id) } clean, err := s.validate(patch, false) if err != nil { return nil, err } rec, err := s.repo().Update(ctx, ident, id, clean) if err != nil { return nil, err } if rec == nil { return nil, domain.NotFound(s.res.Name, id) } return rec, nil } // Delete removes a record and always reports success. // // store.js filters its array and returns { id } whether or not anything // matched, and both live callers delete inside loops without checking. A 404 // here would surface an error toast where none appears today. // See api-contract.md §12.7. func (s *Service) Delete(ctx context.Context, ident authctx.Identity, id string) (domain.Record, error) { if !isUUID(id) { return domain.Record{"id": id}, nil } if _, err := s.repo().Delete(ctx, ident, id); err != nil { return nil, err } return domain.Record{"id": id}, nil } /* ── Validation ─────────────────────────────────────────────────────────── */ // validate checks a request body against the resource's columns and returns a // copy with server-owned fields removed. // // Unknown fields are rejected rather than ignored. Silently dropping them is // exactly how `interview_id`, `training_outline` and `score_breakdown` would // have been lost: the frontend would have written them, the API would have // accepted the request, and the data would never have arrived. func (s *Service) validate(in domain.Record, isCreate bool) (domain.Record, error) { details := map[string]string{} out := make(domain.Record, len(in)) for name, value := range in { col, ok := s.res.Column(name) if !ok { details[name] = "unknown field" continue } if col.ReadOnly { continue // server-owned: ignored, not rejected (api-contract.md §3.1) } if value == nil { if col.NotNull { details[name] = "must not be null" continue } out[name] = nil continue } if col.Kind == domain.KindEnum { str, isStr := value.(string) if !isStr || !contains(col.Enum, str) { details[name] = fmt.Sprintf("must be one of: %s", strings.Join(col.Enum, ", ")) continue } } out[name] = value } if isCreate { for _, col := range s.res.Columns { if !col.Required { continue } if s.serverSupplies(col.Name) { // The repository fills this from the session, so demanding it // from the caller would reject a request the server is about to // complete correctly. evidence.worker_email is the live case. continue } v, ok := out[col.Name] if !ok { details[col.Name] = "required" continue } if str, isStr := v.(string); isStr && strings.TrimSpace(str) == "" { details[col.Name] = "must not be blank" } } } if len(details) > 0 { return nil, domain.Validation( fmt.Sprintf("%s payload is not valid", s.res.Name), details) } return out, nil } // serverSupplies reports whether a column is filled in from the authenticated // session rather than from the request body. func (s *Service) serverSupplies(name string) bool { if s.res.Policy == nil { return false } for _, d := range s.res.Policy.Derived { if d.Column == name { return true } } return false } func contains(set []string, v string) bool { for _, s := range set { if s == v { return true } } return false } // isUUID reports whether a string is shaped like a canonical UUID. Cheap enough // to run per request and it keeps a malformed id out of the SQL entirely. func isUUID(s string) bool { if len(s) != 36 { return false } for i, c := range s { switch i { case 8, 13, 18, 23: if c != '-' { return false } default: isHex := (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F') if !isHex { return false } } } return true } /* ── Registry ───────────────────────────────────────────────────────────── */ // Registry holds one service per resource that has an endpoint. type Registry struct { byPath map[string]*Service order []*Service } // NewRegistry builds services for every resource in domain.AllResources. func NewRegistry(db repo.Querier) *Registry { reg := &Registry{byPath: make(map[string]*Service, len(domain.AllResources))} for _, res := range domain.AllResources { svc := New(res, db) reg.byPath[res.Path] = svc reg.order = append(reg.order, svc) } return reg } // Get returns the service for a URL path segment. func (r *Registry) Get(path string) (*Service, bool) { s, ok := r.byPath[path] return s, ok } // All returns every service, in declaration order. func (r *Registry) All() []*Service { return r.order }