package recruitmatch import ( "context" "errors" "time" "github.com/jackc/pgx/v5" "github.com/missionworkforce/missionmatch/internal/platform/audit" "github.com/missionworkforce/missionmatch/internal/platform/tenant" ) // ErrNoDatabase is returned when the service was built without a pool, which the handler maps to 503. var ErrNoDatabase = errors.New("recruitmatch: no database configured") // topJobsLimit bounds the ranked requirement list. It is a constant rather than a request parameter so // the response size cannot be driven by a caller. const topJobsLimit = 5 // Service computes the recruitMATCH dashboard. It reads only tables the other tools already write and // owns no schema of its own. type Service struct { db *tenant.DB audit *audit.Writer clock func() time.Time } func NewService(db *tenant.DB) *Service { return &Service{db: db, clock: time.Now} } // NewServiceWithAudit keeps parity with the other modules. The dashboard is read only today, so the // writer is currently unused, but the constructor exists so wiring does not change when a write lands. func NewServiceWithAudit(db *tenant.DB, aud *audit.Writer) *Service { return &Service{db: db, audit: aud, clock: time.Now} } // RecruiterDashboard assembles the whole view inside ONE tenant transaction, so every panel observes the // same snapshot and no two numbers on the screen can come from different states of the database. // // The transaction is also the RLS boundary: RunInTenantTx sets app.current_org_id transaction locally, // buildTemps refuses to proceed if it is unset, and the temp tables are populated by RLS filtered reads, // so they contain only this tenant's rows and are dropped at commit. // // No query in this module calls now() or current_date. Time enters only through asOf, so the same asOf // over the same data produces a byte identical view. func (s *Service) RecruiterDashboard(ctx context.Context, asOf time.Time) (View, error) { if s.db == nil { return View{}, ErrNoDatabase } orgID, _ := tenant.OrgFromContext(ctx) asOfStr := asOf.Format("2006-01-02") v := View{ AsOf: asOfStr, OrganizationID: orgID, GeneratedAt: asOf.UTC().Format(time.RFC3339), EmptyPanels: notTracked(), } err := s.db.RunInTenantTx(ctx, func(ctx context.Context, tx pgx.Tx) error { if err := buildTemps(ctx, tx, asOfStr); err != nil { return err } h, err := headline(ctx, tx) if err != nil { return err } v.Headline = h v.Funnel = buildFunnel(h.Identified, h.Screened, h.Submitted, h.Approved) approved, rejected, pending, err := decisionCounts(ctx, tx) if err != nil { return err } v.Decisions = buildDecisions(approved, rejected, pending) if v.BandMix, err = bandMix(ctx, tx); err != nil { return err } if v.Clearance, err = clearanceRows(ctx, tx); err != nil { return err } if v.TopJobs, err = topJobs(ctx, tx, topJobsLimit); err != nil { return err } if v.Recruiters, err = recruiters(ctx, tx); err != nil { return err } return nil }) if err != nil { return View{}, err } return v, nil }