package commandcenter import ( "context" "encoding/json" "errors" "strings" "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/missionworkforce/missionmatch/internal/platform/audit" "github.com/missionworkforce/missionmatch/internal/platform/database/dbgen" "github.com/missionworkforce/missionmatch/internal/platform/tenant" ) var validLevels = map[string]bool{"none": true, "confidential": true, "secret": true, "top_secret": true, "ts_sci": true} var validPolys = map[string]bool{"none": true, "ci": true, "full_scope": true} // AdoptForecast turns a planning stage forecast into a real, tracked requirement the shared engine then // scores like any other (the adopt to score path; no parallel scoring). It reads the public forecast for // its title, agency, and place of performance, then creates a standalone requirement (no opportunity) // with the clearance, years, and mandatory skills the operator supplies, all inside one tenant // transaction so the new requirement is RLS scoped to the caller's org. The create is recorded to the // append only audit trail. func (s *Service) AdoptForecast(ctx context.Context, recordID string, in AdoptForecastInput) (AdoptForecastResult, error) { if s.db == nil { return AdoptForecastResult{}, ErrNoDatabase } actor, ok := tenant.UserFromContext(ctx) if !ok { return AdoptForecastResult{}, tenant.ErrNoTenant } orgStr, ok := tenant.OrgFromContext(ctx) if !ok { return AdoptForecastResult{}, tenant.ErrNoTenant } orgID, err := uuid.Parse(orgStr) if err != nil { return AdoptForecastResult{}, tenant.ErrNoTenant } level := "none" if l := strings.TrimSpace(strings.ToLower(in.RequiredLevel)); validLevels[l] { level = l } poly := "none" if p := strings.TrimSpace(strings.ToLower(in.RequiredPoly)); validPolys[p] { poly = p } years := in.RequiredYears if years < 0 { years = 0 } var res AdoptForecastResult err = s.db.RunInTenantTx(ctx, func(ctx context.Context, tx pgx.Tx) error { q := dbgen.New(tx) var kind, title, agency, popState, popCity string rerr := tx.QueryRow(ctx, `SELECT record_kind, title, agency, COALESCE(pop_state,''), COALESCE(pop_city,'') FROM opportunity_records WHERE id = $1`, recordID).Scan(&kind, &title, &agency, &popState, &popCity) if errors.Is(rerr, pgx.ErrNoRows) { return ErrForecastNotFound } if rerr != nil { return rerr } if kind != "forecast" { return ErrNotAForecast } reqTitle := strings.TrimSpace(title) if reqTitle == "" { reqTitle = "Adopted forecast requirement" } loc := strings.TrimSpace(strings.TrimSpace(popCity) + " " + strings.TrimSpace(popState)) row, cerr := q.CreateRequirement(ctx, dbgen.CreateRequirementParams{ OrganizationID: orgID, OpportunityID: nil, Title: reqTitle, CustomerAgency: strNonEmpty(agency), RequiredLevel: level, RequiredPoly: poly, RequiredYears: int32(years), WorkLocation: strNonEmpty(loc), RemoteAllowed: false, KeyPersonnel: in.KeyPersonnel, }) if cerr != nil { return cerr } res.RequirementID = row.ID.String() res.Title = reqTitle for _, name := range in.Skills { name = strings.TrimSpace(name) if name == "" { continue } sk, serr := q.UpsertSkill(ctx, dbgen.UpsertSkillParams{Name: name}) if serr != nil { return serr } if aerr := q.AddRequiredSkill(ctx, dbgen.AddRequiredSkillParams{ OrganizationID: orgID, RequirementID: row.ID, SkillID: sk.ID, Mandatory: true, }); aerr != nil { return aerr } } if s.audit != nil { nv, merr := json.Marshal(map[string]string{"source": "forecast", "forecast_record_id": recordID}) if merr != nil { return merr } if aerr := s.audit.OnTx(ctx, tx, audit.Event{ Action: "requirement.adopted_from_forecast", ActorUserID: actorPtr(actor.UserID), EntityType: "requirement", EntityID: row.ID.String(), NewValues: nv, }); aerr != nil { return aerr } } return nil }) return res, err } func strNonEmpty(s string) *string { s = strings.TrimSpace(s) if s == "" { return nil } return &s } func actorPtr(s string) *uuid.UUID { id, err := uuid.Parse(s) if err != nil { return nil } return &id }