201 lines
9.3 KiB
Go
201 lines
9.3 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net/url"
|
|
"strings"
|
|
|
|
"github.com/krow/krow-backend/go-api/internal/authctx"
|
|
"github.com/krow/krow-backend/go-api/internal/definition"
|
|
"github.com/krow/krow-backend/go-api/internal/domain"
|
|
"github.com/krow/krow-backend/go-api/internal/owliver"
|
|
"github.com/krow/krow-backend/go-api/internal/repo"
|
|
)
|
|
|
|
// allowedSuggestionParams names the accepted query parameters, on the pattern
|
|
// of allowedDefinitionFilters: anything else is a caller mistake worth saying
|
|
// out loud rather than a filter to ignore. It also keeps the endpoint from
|
|
// quietly accepting a `role`, `org` or `user` parameter should one ever be
|
|
// added by a client — who is asking is read from the session and nowhere else.
|
|
var allowedSuggestionParams = map[string]bool{
|
|
"page": true,
|
|
"query": true,
|
|
}
|
|
|
|
// maxEchoedPage bounds how much of a rejected page value is quoted back. Long
|
|
// enough to name every real surface, short enough that the error cannot be used
|
|
// to reflect a payload.
|
|
const maxEchoedPage = 64
|
|
|
|
// SuggestionQuery is a validated suggestion request.
|
|
//
|
|
// Page is canonical: aliases are resolved here so nothing downstream has to
|
|
// know that `hired` and `hired-history` are the same surface.
|
|
type SuggestionQuery struct {
|
|
Page string
|
|
Query string
|
|
}
|
|
|
|
// SuggestionsService answers "what could I usefully ask on this page?".
|
|
//
|
|
// It answers two different questions through one endpoint, and the difference
|
|
// is whether anything was typed:
|
|
//
|
|
// - Something typed — ranked against the static catalogue in
|
|
// internal/owliver. No database, because the panel issues one of these per
|
|
// keystroke and the work has to stay a few string comparisons.
|
|
// - Nothing typed — ranked against the ORGANIZATION'S ACTUAL STATE, read from
|
|
// PostgreSQL here. That is one query per opened panel, and it is what makes
|
|
// a suggestion react to the data: a workspace with three unfinished drafts
|
|
// is asked a different question from one with eleven candidates waiting on
|
|
// a decision, and creating a position changes what comes back next time.
|
|
//
|
|
// The pool is held for the second path only. Built without one — NewSuggestions
|
|
// with a nil pool — the service still serves the typed path exactly as before,
|
|
// which is what keeps it constructible where there is no database.
|
|
type SuggestionsService struct {
|
|
db repo.Querier
|
|
}
|
|
|
|
// NewSuggestions builds the suggestion service over a pool.
|
|
//
|
|
// A nil pool is legal and means "no organization context": the typed path is
|
|
// unaffected and the untyped path answers with an empty list rather than
|
|
// guessing. Nothing here invents context to make suggestions appear.
|
|
func NewSuggestions(db repo.Querier) *SuggestionsService {
|
|
return &SuggestionsService{db: db}
|
|
}
|
|
|
|
// ParseParams validates the query string.
|
|
//
|
|
// `page` is required and must name a real surface — the same closed vocabulary
|
|
// internal/definition validates a definition's `pages:` against, so there is
|
|
// one answer to "is that a page" in this process. `query` is optional: an
|
|
// absent one is a request for what the data itself suggests rather than an
|
|
// error.
|
|
func (s *SuggestionsService) ParseParams(q url.Values) (SuggestionQuery, error) {
|
|
var out SuggestionQuery
|
|
|
|
for name := range q {
|
|
if !allowedSuggestionParams[name] {
|
|
return out, domain.Invalid(fmt.Sprintf("unknown parameter %q", name))
|
|
}
|
|
}
|
|
|
|
raw := strings.TrimSpace(q.Get("page"))
|
|
if raw == "" {
|
|
return out, domain.Invalid("page is required")
|
|
}
|
|
page := definition.CanonicalPage(raw)
|
|
if page == "" {
|
|
echoed := raw
|
|
if len(echoed) > maxEchoedPage {
|
|
echoed = echoed[:maxEchoedPage]
|
|
}
|
|
return out, domain.Invalid(fmt.Sprintf(
|
|
"Unsupported page: %s. Supported pages: %s.",
|
|
echoed, strings.Join(definition.SupportedPages, ", ")))
|
|
}
|
|
|
|
out.Page = page
|
|
out.Query = q.Get("query")
|
|
return out, nil
|
|
}
|
|
|
|
// Suggest ranks the page's readings for this caller.
|
|
//
|
|
// The role comes off the session-resolved identity, exactly as Server.authorize
|
|
// reads it, and an unrecognised role is offered nothing — the same deny-by-
|
|
// default the policy table applies. Nothing else about the caller is consulted:
|
|
// there is no branch here on organization, account type or anything a request
|
|
// could set, beyond the organization whose rows are counted.
|
|
//
|
|
// No error case beyond parsing. A page with no readings for this caller, a
|
|
// query that matches none of them, and an organization with nothing worth
|
|
// remarking on all answer with an empty list — an empty result is an answer,
|
|
// not a failure. A context read that fails degrades to that same empty list:
|
|
// the panel opening with no suggestions is a smaller failure than the panel
|
|
// refusing to open, and a fixed list would read as a real finding.
|
|
func (s *SuggestionsService) Suggest(ctx context.Context, ident authctx.Identity, q SuggestionQuery) []owliver.Suggestion {
|
|
role, known := domain.ParseRole(ident.Role)
|
|
if !known {
|
|
return []owliver.Suggestion{}
|
|
}
|
|
|
|
// Anything typed is a question about words, answered from the catalogue.
|
|
// Deliberately checked here rather than inside Suggest so that a query too
|
|
// short to rank does NOT fall through to the untyped path: the user is
|
|
// mid-word, and replacing what they are typing towards with three unrelated
|
|
// readings of the database is the flicker this ordering avoids.
|
|
if strings.TrimSpace(q.Query) != "" {
|
|
return owliver.Suggest(q.Page, q.Query, role)
|
|
}
|
|
|
|
return owliver.Highlights(q.Page, s.contextFor(ctx, ident, role), role)
|
|
}
|
|
|
|
/* ── Organization context ───────────────────────────────────────────────── */
|
|
|
|
// contextQuery counts the organization, once.
|
|
//
|
|
// One statement rather than fifteen, because it is on the path that opens the
|
|
// panel and fifteen round trips would be felt. Every count is a scalar subquery
|
|
// over one org's rows, so nothing here can return a record, a name or an id —
|
|
// see the note on owliver.Context.
|
|
//
|
|
// `starved_positions` and `underfilled_active` are the two that are not a plain
|
|
// tally: a posting nobody has applied to, and a posting with fewer people
|
|
// placed than it asked for. Those are the states that make a position worth
|
|
// raising unprompted, and they are computed in SQL because the alternative is
|
|
// reading every posting and every application into memory to count two
|
|
// integers.
|
|
const contextQuery = `
|
|
SELECT
|
|
(SELECT count(*) FROM job_postings WHERE org_id = $1 AND status = 'active') AS active_positions,
|
|
(SELECT count(*) FROM job_postings WHERE org_id = $1 AND status = 'draft') AS draft_positions,
|
|
(SELECT count(*) FROM job_postings p WHERE p.org_id = $1 AND p.status = 'active'
|
|
AND NOT EXISTS (SELECT 1 FROM job_applications a WHERE a.job_posting_id = p.id)) AS starved_positions,
|
|
(SELECT count(*) FROM job_postings p WHERE p.org_id = $1 AND p.status = 'active'
|
|
AND p.headcount > (SELECT count(*) FROM job_applications a
|
|
WHERE a.job_posting_id = p.id AND a.status IN ('hired','assigned'))) AS underfilled_active,
|
|
(SELECT count(*) FROM job_applications WHERE org_id = $1) AS applications,
|
|
(SELECT count(*) FROM job_applications WHERE org_id = $1 AND status = 'applied') AS unscreened,
|
|
(SELECT count(*) FROM job_applications WHERE org_id = $1 AND status = 'shortlisted') AS shortlisted,
|
|
(SELECT count(*) FROM job_applications WHERE org_id = $1 AND status = 'interview') AS interviewing,
|
|
(SELECT count(*) FROM job_applications WHERE org_id = $1 AND status = 'hired') AS hired,
|
|
(SELECT count(*) FROM ai_interviews WHERE org_id = $1 AND cardinality(ai_flags) > 0) AS flagged_risks,
|
|
(SELECT count(*) FROM staff WHERE org_id = $1) AS staff,
|
|
(SELECT count(*) FROM worker_profiles WHERE org_id = $1) AS profiles,
|
|
(SELECT count(*) FROM worker_profiles w WHERE w.org_id = $1
|
|
AND NOT EXISTS (SELECT 1 FROM evidence e WHERE e.worker_email = w.email)) AS unverified_profiles,
|
|
(SELECT count(*) FROM courses WHERE org_id = $1) AS courses,
|
|
(SELECT count(*) FROM user_activity WHERE org_id = $1) AS activity_events
|
|
`
|
|
|
|
// contextFor reads the organization's state, or gives up honestly.
|
|
//
|
|
// A talent caller gets the zero Context and no query is run. That is not a
|
|
// performance choice: every count above is org-wide, and talent's rows are
|
|
// narrowed by the policy table, so answering "eleven candidates are waiting" to
|
|
// someone entitled to see one of them would leak the other ten through an
|
|
// integer. Their own readings — the Profile page's — carry no org-wide Need and
|
|
// are ranked without any of this.
|
|
func (s *SuggestionsService) contextFor(ctx context.Context, ident authctx.Identity, role domain.Role) owliver.Context {
|
|
var out owliver.Context
|
|
if s.db == nil || ident.OrgID == "" || role == domain.RoleTalent {
|
|
return out
|
|
}
|
|
|
|
err := s.db.QueryRow(ctx, contextQuery, ident.OrgID).Scan(
|
|
&out.ActivePositions, &out.DraftPositions, &out.StarvedPositions, &out.UnderfilledActive,
|
|
&out.Applications, &out.Unscreened, &out.Shortlisted, &out.Interviewing, &out.Hired,
|
|
&out.FlaggedRisks,
|
|
&out.Staff, &out.Profiles, &out.UnverifiedProfiles, &out.Courses, &out.ActivityEvents,
|
|
)
|
|
if err != nil {
|
|
return owliver.Context{}
|
|
}
|
|
return out
|
|
}
|