package ui import ( "encoding/json" "net/http" "strconv" "github.com/hireme/hireme/internal/config" "github.com/hireme/hireme/internal/db" "github.com/hireme/hireme/internal/logger" "github.com/hireme/hireme/internal/worker" ) type Server struct { cfg *config.Config db *db.DB pool *worker.WorkerPool } func NewServer(cfg *config.Config, database *db.DB, pool *worker.WorkerPool) *Server { return &Server{ cfg: cfg, db: database, pool: pool, } } func (s *Server) RegisterRoutes(mux *http.ServeMux) { mux.HandleFunc("GET /", s.handleIndex) mux.HandleFunc("GET /targets", s.handleGetTargets) mux.HandleFunc("POST /targets", s.handleCreateTarget) mux.HandleFunc("POST /targets/{id}/toggle", s.handleToggleTarget) mux.HandleFunc("GET /jobs", s.handleGetJobs) mux.HandleFunc("GET /jobs/{id}", s.handleGetJobDetail) mux.HandleFunc("POST /jobs/{id}/analyze", s.handleAnalyzeJob) mux.HandleFunc("POST /jobs/{id}/opt-out", s.handleOptOut) mux.HandleFunc("GET /applications", s.handleGetApplications) mux.HandleFunc("POST /applications", s.handleCreateApplication) mux.HandleFunc("PATCH /applications/{id}/status", s.handleUpdateAppStatus) mux.HandleFunc("POST /api/tasks/discover", s.handleDiscoverTasks) mux.HandleFunc("POST /api/tasks/clean", s.handleCleanTasks) mux.HandleFunc("POST /api/tasks/reprocess", s.handleReprocessJobs) mux.HandleFunc("POST /api/tasks/toggle", s.handleToggleTasks) mux.HandleFunc("GET /api/status", s.handleGetStatus) } func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/" { http.NotFound(w, r) return } targets, err := s.db.GetAllTargets() if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } jobs, err := s.db.GetJobsWithAnalysis() if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } apps, err := s.db.GetApplications() if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } qualifiedCount := 0 for _, j := range jobs { if j.Analysis != nil && j.Analysis.IsQualified { qualifiedCount++ } } data := PageData{ Targets: targets, Jobs: jobs, Applications: apps, ActiveTab: "triage", LLMProvider: s.cfg.LLMProvider, QualifiedJobsCount: qualifiedCount, TotalIngestedCount: len(jobs), ActiveApplications: len(apps), } w.Header().Set("Content-Type", "text/html; charset=utf-8") if err := RenderPage(w, data); err != nil { logger.Log.Errorf("[UI] RenderPage error: %v", err) } } func (s *Server) handleGetTargets(w http.ResponseWriter, r *http.Request) { targets, err := s.db.GetAllTargets() if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "text/html; charset=utf-8") _ = RenderTargetList(w, targets) } func (s *Server) handleCreateTarget(w http.ResponseWriter, r *http.Request) { if err := r.ParseForm(); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } name := r.FormValue("name") targetURL := r.FormValue("target_url") platform := r.FormValue("platform") if name == "" || targetURL == "" { http.Error(w, "name and target_url are required", http.StatusBadRequest) return } _, err := s.db.CreateTarget(name, targetURL, platform) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } s.handleGetTargets(w, r) } func (s *Server) handleToggleTarget(w http.ResponseWriter, r *http.Request) { idStr := r.PathValue("id") id, err := strconv.ParseInt(idStr, 10, 64) if err != nil { http.Error(w, "invalid target id", http.StatusBadRequest) return } if err := s.db.ToggleTargetStatus(id); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } s.handleGetTargets(w, r) } func (s *Server) handleGetJobs(w http.ResponseWriter, r *http.Request) { jobs, err := s.db.GetJobsWithAnalysis() if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "text/html; charset=utf-8") _ = RenderJobList(w, jobs) } func (s *Server) handleGetJobDetail(w http.ResponseWriter, r *http.Request) { idStr := r.PathValue("id") id, err := strconv.ParseInt(idStr, 10, 64) if err != nil { http.Error(w, "invalid job id", http.StatusBadRequest) return } job, err := s.db.GetJobByID(id) if err != nil { http.Error(w, "job not found", http.StatusNotFound) return } w.Header().Set("Content-Type", "text/html; charset=utf-8") _ = RenderJobDetail(w, job) } func (s *Server) handleAnalyzeJob(w http.ResponseWriter, r *http.Request) { idStr := r.PathValue("id") id, err := strconv.ParseInt(idStr, 10, 64) if err != nil { http.Error(w, "invalid job id", http.StatusBadRequest) return } // Enqueue analyze task instead of synchronous processing payload := worker.AnalyzePayload{RawJobID: int64(id)} err = s.db.EnqueueTask("ANALYZE", payload) if err != nil { http.Error(w, "Failed to enqueue analysis task: "+err.Error(), http.StatusInternalServerError) return } w.WriteHeader(http.StatusAccepted) _, _ = w.Write([]byte(`Analysis Queued`)) } func (s *Server) handleOptOut(w http.ResponseWriter, r *http.Request) { idStr := r.PathValue("id") id, err := strconv.ParseInt(idStr, 10, 64) if err != nil { http.Error(w, "invalid job id", http.StatusBadRequest) return } _, err = s.db.CreateOrUpdateApplication(id, "OPTED_OUT", "") if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } jobs, err := s.db.GetJobsWithAnalysis() if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "text/html; charset=utf-8") _ = RenderJobList(w, jobs) } func (s *Server) handleGetApplications(w http.ResponseWriter, r *http.Request) { apps, err := s.db.GetApplications() if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "text/html; charset=utf-8") _ = RenderApplyTable(w, apps) } func (s *Server) handleCreateApplication(w http.ResponseWriter, r *http.Request) { if err := r.ParseForm(); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } rawJobIDStr := r.FormValue("raw_job_id") status := r.FormValue("status") if status == "" { status = "SAVED" } rawJobID, err := strconv.ParseInt(rawJobIDStr, 10, 64) if err != nil { http.Error(w, "invalid raw_job_id", http.StatusBadRequest) return } _, err = s.db.CreateOrUpdateApplication(rawJobID, status, "") if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } // Always return the updated job list so the action column refreshes s.handleGetJobs(w, r) } func (s *Server) handleUpdateAppStatus(w http.ResponseWriter, r *http.Request) { idStr := r.PathValue("id") appID, err := strconv.ParseInt(idStr, 10, 64) if err != nil { http.Error(w, "invalid app id", http.StatusBadRequest) return } if err := r.ParseForm(); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } newStatus := r.FormValue("status") if newStatus == "" { http.Error(w, "status required", http.StatusBadRequest) return } // Fetch rawJobID for appID apps, err := s.db.GetApplications() if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } var targetRawJobID int64 = -1 for _, a := range apps { if a.App != nil && a.App.ID == appID { targetRawJobID = a.ID break } } if targetRawJobID == -1 { http.Error(w, "application not found", http.StatusNotFound) return } _, err = s.db.CreateOrUpdateApplication(targetRawJobID, newStatus, "") if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } updatedApps, _ := s.db.GetApplications() w.Header().Set("Content-Type", "text/html; charset=utf-8") _ = RenderApplyTable(w, updatedApps) } func (s *Server) handleDiscoverTasks(w http.ResponseWriter, r *http.Request) { logger.Log.Info("[API] Triggered /api/tasks/discover") targets, err := s.db.GetAllTargets() if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } count := 0 for _, target := range targets { if !target.IsActive { continue } payload := worker.DiscoverPayload{ TargetID: target.ID, TargetURL: target.TargetURL, Platform: target.Platform, } if err := s.db.EnqueueTask("DISCOVER", payload); err == nil { count++ } } w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"status": "success", "message": "Queued ` + strconv.Itoa(count) + ` discovery tasks"}`)) } func (s *Server) handleCleanTasks(w http.ResponseWriter, r *http.Request) { logger.Log.Info("[API] Triggered /api/tasks/clean") err := s.db.ClearTaskQueue() if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"status": "success"}`)) } func (s *Server) handleReprocessJobs(w http.ResponseWriter, r *http.Request) { logger.Log.Info("[API] Triggered /api/tasks/reprocess") if err := s.db.ReprocessAllJobs(); err != nil { logger.Log.Errorf("[API] Failed to reprocess jobs: %v", err) http.Error(w, err.Error(), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"status": "success", "message": "All jobs queued for reprocessing"}`)) } func (s *Server) handleToggleTasks(w http.ResponseWriter, r *http.Request) { logger.Log.Info("[API] Triggered /api/tasks/toggle") isRunning := s.pool.Toggle() status := "paused" if isRunning { status = "running" } w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"status": "` + status + `"}`)) } // handleGetStatus returns a JSON snapshot of worker pool state and task queue counts. // Used by the bottom bar to auto-refresh every few seconds without a full page reload. func (s *Server) handleGetStatus(w http.ResponseWriter, r *http.Request) { workerStatus := "paused" if s.pool.IsRunning() { workerStatus = "running" } stats, err := s.db.GetQueueStats() if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } resp := map[string]interface{}{ "worker_status": workerStatus, "queue": stats, } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(resp) }