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 }