diff --git a/data/config/targets.yaml b/data/config/targets.yaml index 01a3aaa..0d6ae74 100644 --- a/data/config/targets.yaml +++ b/data/config/targets.yaml @@ -1,13 +1,13 @@ targets: - - name: "Google Software Engineer (Seattle, MID Level)" - target_url: "https://www.google.com/about/careers/applications/jobs/results/?employment_type=FULL_TIME&q=%22Software%20Engineer%22&location=Seattle%2C%20WA%2C%20USA&target_level=MID" - platform: "google_jobs" - is_active: true - - name: "Apple Software Engineer (Seattle)" - target_url: "https://jobs.apple.com/en-us/search?location=seattle-SEA&key=Software%2520Engineer&team=apps-and-frameworks-SFTWR-AF+cloud-and-infrastructure-SFTWR-CLD+core-operating-systems-SFTWR-COS+devops-and-site-reliability-SFTWR-DSR+engineering-project-management-SFTWR-EPM+information-systems-and-technology-SFTWR-ISTECH+machine-learning-and-ai-SFTWR-MCHLN+security-and-privacy-SFTWR-SEC+software-quality-automation-and-tools-SFTWR-SQAT+wireless-software-SFTWR-WSFT+machine-learning-infrastructure-MLAI-MLI+deep-learning-and-reinforcement-learning-MLAI-DLRL+natural-language-processing-and-speech-technologies-MLAI-NLP+computer-vision-MLAI-CV+applied-research-MLAI-ARr" - platform: "apple" - is_active: true - - name: "Meta Software Engineer (Seattle)" - target_url: "https://www.metacareers.com/jobsearch/?offices[0]=Seattle%2C%20WA&roles[0]=Full%20time%20employment&teams[0]=Software%20Engineering" - platform: "meta" - is_active: true \ No newline at end of file + - name: Google Software Engineer (Seattle, MID Level) + target_url: https://www.google.com/about/careers/applications/jobs/results/?employment_type=FULL_TIME&q=%22Software%20Engineer%22&location=Seattle%2C%20WA%2C%20USA&target_level=MID + platform: google_jobs + is_active: true + - name: Apple Software Engineer (Seattle) + target_url: https://jobs.apple.com/en-us/search?location=seattle-SEA&key=Software%2520Engineer&team=apps-and-frameworks-SFTWR-AF+cloud-and-infrastructure-SFTWR-CLD+core-operating-systems-SFTWR-COS+devops-and-site-reliability-SFTWR-DSR+engineering-project-management-SFTWR-EPM+information-systems-and-technology-SFTWR-ISTECH+machine-learning-and-ai-SFTWR-MCHLN+security-and-privacy-SFTWR-SEC+software-quality-automation-and-tools-SFTWR-SQAT+wireless-software-SFTWR-WSFT+machine-learning-infrastructure-MLAI-MLI+deep-learning-and-reinforcement-learning-MLAI-DLRL+natural-language-processing-and-speech-technologies-MLAI-NLP+computer-vision-MLAI-CV+applied-research-MLAI-ARr + platform: apple + is_active: true + - name: Meta Software Engineer (Seattle) + target_url: https://www.metacareers.com/jobsearch/?offices[0]=Seattle%2C%20WA&roles[0]=Full%20time%20employment&teams[0]=Software%20Engineering + platform: meta + is_active: true diff --git a/internal/db/queries.go b/internal/db/queries.go index 55fe354..baf845f 100644 --- a/internal/db/queries.go +++ b/internal/db/queries.go @@ -2,6 +2,7 @@ package db import ( "database/sql" + "fmt" "math" "regexp" "strings" @@ -83,6 +84,86 @@ func (db *DB) UpdateTargetCrawledAt(id int64) error { return err } +func (db *DB) DeleteTarget(id int64) (*SearchTarget, int64, error) { + tx, err := db.Begin() + if err != nil { + return nil, 0, err + } + defer func() { _ = tx.Rollback() }() + + // 1. Get target info before deleting + var target SearchTarget + var lastCrawled sql.NullTime + err = tx.QueryRow(`SELECT id, name, target_url, platform, is_active, last_crawled_at FROM search_targets WHERE id = ?`, id).Scan( + &target.ID, &target.Name, &target.TargetURL, &target.Platform, &target.IsActive, &lastCrawled, + ) + if err != nil { + return nil, 0, err + } + if lastCrawled.Valid { + target.LastCrawledAt = &lastCrawled.Time + } + + // 2. Find all raw_jobs IDs associated with this target + rows, err := tx.Query(`SELECT id, canonical_id FROM raw_jobs WHERE target_id = ?`, id) + if err != nil { + return nil, 0, err + } + var rawJobIDs []int64 + var canonicalIDs []string + for rows.Next() { + var rID int64 + var cID string + if err := rows.Scan(&rID, &cID); err != nil { + _ = rows.Close() + return nil, 0, err + } + rawJobIDs = append(rawJobIDs, rID) + canonicalIDs = append(canonicalIDs, cID) + } + _ = rows.Close() + + // 3. Delete tasks referencing this target or its discovered jobs + targetIDPattern := fmt.Sprintf(`%%"target_id":%d%%`, id) + targetIDPatternStr := fmt.Sprintf(`%%"target_id": %d%%`, id) + _, _ = tx.Exec(`DELETE FROM tasks WHERE payload LIKE ? OR payload LIKE ?`, targetIDPattern, targetIDPatternStr) + + for _, cid := range canonicalIDs { + cPattern := fmt.Sprintf(`%%"canonical_id":"%s"%%`, cid) + _, _ = tx.Exec(`DELETE FROM tasks WHERE payload LIKE ?`, cPattern) + } + for _, rID := range rawJobIDs { + rPattern := fmt.Sprintf(`%%"raw_job_id":%d%%`, rID) + rPatternStr := fmt.Sprintf(`%%"raw_job_id": %d%%`, rID) + _, _ = tx.Exec(`DELETE FROM tasks WHERE payload LIKE ? OR payload LIKE ?`, rPattern, rPatternStr) + } + + // 4. Delete job_applications and job_analyses for these raw_jobs + for _, rID := range rawJobIDs { + _, _ = tx.Exec(`DELETE FROM job_applications WHERE raw_job_id = ?`, rID) + _, _ = tx.Exec(`DELETE FROM job_analyses WHERE raw_job_id = ?`, rID) + } + + // 5. Delete raw_jobs for this target + res, err := tx.Exec(`DELETE FROM raw_jobs WHERE target_id = ?`, id) + if err != nil { + return nil, 0, err + } + deletedJobsCount, _ := res.RowsAffected() + + // 6. Delete search_target + _, err = tx.Exec(`DELETE FROM search_targets WHERE id = ?`, id) + if err != nil { + return nil, 0, err + } + + if err := tx.Commit(); err != nil { + return nil, 0, err + } + + return &target, deletedJobsCount, nil +} + // Raw Jobs Queries & Vector Similarity Deduplication func (db *DB) InsertRawJob(canonicalID string, targetID *int64, title, company, location, applyURL, rawContent string) (*RawJob, bool, error) { // Deduplication Check 1 & 2: Check canonical_id exist AND vector similarity >= 99% @@ -207,8 +288,6 @@ func tokenizeToVector(text string) map[string]int { return freqMap } - - // Job Analyses Queries func (db *DB) CreateJobAnalysis(rawJobID int64, score float64, isQualified bool, status string) error { query := ` diff --git a/internal/db/queries_test.go b/internal/db/queries_test.go index 885de49..8ebdf94 100644 --- a/internal/db/queries_test.go +++ b/internal/db/queries_test.go @@ -1,7 +1,9 @@ package db import ( + "os" "path/filepath" + "strings" "testing" ) @@ -53,3 +55,130 @@ func TestVectorSimilarityDeduplication(t *testing.T) { t.Errorf("first job mismatch") } } + +func TestDeleteTargetCascade(t *testing.T) { + tempDir := t.TempDir() + dbPath := filepath.Join(tempDir, "test_delete_target.db") + + database, err := InitDB(dbPath) + if err != nil { + t.Fatalf("failed to init db: %v", err) + } + defer func() { _ = database.Close() }() + + // 1. Create two search targets + target1, err := database.CreateTarget("Target 1", "https://example.com/target1", "google_jobs") + if err != nil { + t.Fatalf("failed to create target 1: %v", err) + } + target2, err := database.CreateTarget("Target 2", "https://example.com/target2", "apple") + if err != nil { + t.Fatalf("failed to create target 2: %v", err) + } + + // 2. Insert raw jobs for target 1 and target 2 + job1, _, err := database.InsertRawJob("canon-t1-j1", &target1.ID, "Job 1", "Google", "Seattle", "https://google.com/1", "Software Engineer Go") + if err != nil { + t.Fatalf("failed to insert job 1: %v", err) + } + job2, _, err := database.InsertRawJob("canon-t1-j2", &target1.ID, "Job 2", "Google", "Seattle", "https://google.com/2", "Software Engineer Python") + if err != nil { + t.Fatalf("failed to insert job 2: %v", err) + } + job3, _, err := database.InsertRawJob("canon-t2-j1", &target2.ID, "Job 3", "Apple", "Seattle", "https://apple.com/1", "Software Engineer iOS") + if err != nil { + t.Fatalf("failed to insert job 3: %v", err) + } + + // 3. Create analyses and applications for target 1 jobs and target 2 jobs + _ = database.CreateJobAnalysis(job1.ID, 85.0, true, "DONE") + _ = database.CreateJobAnalysis(job2.ID, 40.0, false, "DONE") + _ = database.CreateJobAnalysis(job3.ID, 90.0, true, "DONE") + + _, _ = database.CreateOrUpdateApplication(job1.ID, "APPLIED", "Sent resume") + _, _ = database.CreateOrUpdateApplication(job3.ID, "SAVED", "Review later") + + // 4. Enqueue tasks + _ = database.EnqueueTask("DISCOVER", map[string]interface{}{"target_id": target1.ID, "target_url": target1.TargetURL}) + _ = database.EnqueueTask("DISCOVER", map[string]interface{}{"target_id": target2.ID, "target_url": target2.TargetURL}) + _ = database.EnqueueTask("PROCESS", map[string]interface{}{"raw_job_id": job1.ID}) + _ = database.EnqueueTask("PROCESS", map[string]interface{}{"raw_job_id": job3.ID}) + + // 5. Delete target 1 + deletedTarget, deletedJobsCount, err := database.DeleteTarget(target1.ID) + if err != nil { + t.Fatalf("DeleteTarget failed: %v", err) + } + if deletedTarget.ID != target1.ID { + t.Errorf("expected deleted target ID %d, got %d", target1.ID, deletedTarget.ID) + } + if deletedJobsCount != 2 { + t.Errorf("expected 2 deleted jobs, got %d", deletedJobsCount) + } + + // 6. Verify Target 1 is gone, Target 2 remains + allTargets, err := database.GetAllTargets() + if err != nil { + t.Fatalf("GetAllTargets failed: %v", err) + } + if len(allTargets) != 1 || allTargets[0].ID != target2.ID { + t.Errorf("expected 1 remaining target (target 2), got %v", allTargets) + } + + // 7. Verify Target 1's jobs are gone, Target 2's job remains + jobsWithAnalysis, err := database.GetJobsWithAnalysis() + if err != nil { + t.Fatalf("GetJobsWithAnalysis failed: %v", err) + } + if len(jobsWithAnalysis) != 1 || jobsWithAnalysis[0].ID != job3.ID { + t.Errorf("expected only job 3 to remain, got %v", jobsWithAnalysis) + } + + // 8. Verify applications for target 1 are gone, target 2 application remains + apps, err := database.GetApplications() + if err != nil { + t.Fatalf("GetApplications failed: %v", err) + } + if len(apps) != 1 || apps[0].ID != job3.ID { + t.Errorf("expected only job 3 application to remain, got %v", apps) + } +} + +func TestRemoveTargetFromYAML(t *testing.T) { + tempDir := t.TempDir() + yamlPath := filepath.Join(tempDir, "targets.yaml") + + initialYAML := `targets: + - name: "Google Seattle" + target_url: "https://google.com/jobs" + platform: "google_jobs" + is_active: true + - name: "Apple Seattle" + target_url: "https://apple.com/jobs" + platform: "apple" + is_active: true +` + if err := os.WriteFile(yamlPath, []byte(initialYAML), 0644); err != nil { + t.Fatalf("failed to write initial yaml: %v", err) + } + + // Remove Google Seattle + if err := RemoveTargetFromYAML(yamlPath, "https://google.com/jobs"); err != nil { + t.Fatalf("RemoveTargetFromYAML failed: %v", err) + } + + // Read back and verify + data, err := os.ReadFile(yamlPath) + if err != nil { + t.Fatalf("failed to read yaml back: %v", err) + } + + content := string(data) + if strings.Contains(content, "https://google.com/jobs") { + t.Errorf("expected google target to be removed, but found in file:\n%s", content) + } + if !strings.Contains(content, "https://apple.com/jobs") { + t.Errorf("expected apple target to remain, but not found in file:\n%s", content) + } +} + diff --git a/internal/db/targets_loader.go b/internal/db/targets_loader.go index 74edd83..2ed2e17 100644 --- a/internal/db/targets_loader.go +++ b/internal/db/targets_loader.go @@ -49,3 +49,44 @@ func (db *DB) LoadTargetsFromYAML(filepath string) error { } return nil } + +// RemoveTargetFromYAML removes a target from the target configuration YAML file if it exists +func RemoveTargetFromYAML(filepath string, targetURL string) error { + if filepath == "" { + return nil + } + data, err := os.ReadFile(filepath) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return fmt.Errorf("could not read target config: %w", err) + } + + var config targetConfig + if err := yaml.Unmarshal(data, &config); err != nil { + return fmt.Errorf("could not parse target config: %w", err) + } + + var newTargets []struct { + Name string `yaml:"name"` + TargetURL string `yaml:"target_url"` + Platform string `yaml:"platform"` + IsActive bool `yaml:"is_active"` + } + + for _, t := range config.Targets { + if t.TargetURL != targetURL { + newTargets = append(newTargets, t) + } + } + + config.Targets = newTargets + outData, err := yaml.Marshal(&config) + if err != nil { + return fmt.Errorf("could not marshal target config: %w", err) + } + + return os.WriteFile(filepath, outData, 0644) +} + diff --git a/internal/ui/handlers.go b/internal/ui/handlers.go index 4a25d5a..78cae57 100644 --- a/internal/ui/handlers.go +++ b/internal/ui/handlers.go @@ -30,6 +30,8 @@ func (s *Server) RegisterRoutes(mux *http.ServeMux) { mux.HandleFunc("GET /targets", s.handleGetTargets) mux.HandleFunc("POST /targets", s.handleCreateTarget) mux.HandleFunc("POST /targets/{id}/toggle", s.handleToggleTarget) + mux.HandleFunc("POST /targets/{id}/delete", s.handleDeleteTarget) + mux.HandleFunc("DELETE /targets/{id}", s.handleDeleteTarget) mux.HandleFunc("GET /jobs", s.handleGetJobs) mux.HandleFunc("GET /jobs/{id}", s.handleGetJobDetail) mux.HandleFunc("POST /jobs/{id}/analyze", s.handleAnalyzeJob) @@ -153,6 +155,35 @@ func (s *Server) handleToggleTarget(w http.ResponseWriter, r *http.Request) { s.handleGetTargets(w, r) } +func (s *Server) handleDeleteTarget(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 + } + + logger.Log.Infow("Triggered /targets/{id}/delete", "component", "API", "target_id", id) + + deletedTarget, deletedJobsCount, err := s.db.DeleteTarget(id) + if err != nil { + logger.Log.Errorw("Failed to delete target", "component", "API", "target_id", id, "error", err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + logger.Log.Infow("Target deleted successfully", "component", "API", "target_id", id, "name", deletedTarget.Name, "deleted_jobs", deletedJobsCount) + + // Also remove from targets.yaml if target_config is configured + if s.cfg != nil && s.cfg.TargetConfig != "" && deletedTarget != nil { + if err := db.RemoveTargetFromYAML(s.cfg.TargetConfig, deletedTarget.TargetURL); err != nil { + logger.Log.Warnw("Could not remove target from targets.yaml", "component", "API", "error", err) + } + } + + s.handleGetTargets(w, r) +} + func (s *Server) handleGetJobs(w http.ResponseWriter, r *http.Request) { jobs, err := s.db.GetJobsWithAnalysis() if err != nil { diff --git a/internal/ui/handlers_test.go b/internal/ui/handlers_test.go new file mode 100644 index 0000000..acae70b --- /dev/null +++ b/internal/ui/handlers_test.go @@ -0,0 +1,84 @@ +package ui + +import ( + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/hireme/hireme/internal/config" + "github.com/hireme/hireme/internal/db" +) + +func TestHandleDeleteTarget(t *testing.T) { + tempDir := t.TempDir() + dbPath := filepath.Join(tempDir, "test_ui_delete_target.db") + yamlPath := filepath.Join(tempDir, "targets.yaml") + + initialYAML := `targets: + - name: "Test Target" + target_url: "https://example.com/test-jobs" + platform: "google_jobs" + is_active: true +` + if err := osWriteFile(yamlPath, []byte(initialYAML)); err != nil { + t.Fatalf("failed to write yaml: %v", err) + } + + database, err := db.InitDB(dbPath) + if err != nil { + t.Fatalf("failed to init db: %v", err) + } + defer func() { _ = database.Close() }() + + target, err := database.CreateTarget("Test Target", "https://example.com/test-jobs", "google_jobs") + if err != nil { + t.Fatalf("failed to create target: %v", err) + } + + _, _, err = database.InsertRawJob("canon-ui-test", &target.ID, "Software Engineer", "Google", "Seattle", "https://example.com/job/1", "Go backend") + if err != nil { + t.Fatalf("failed to insert raw job: %v", err) + } + + cfg := &config.Config{ + TargetConfig: yamlPath, + } + + server := NewServer(cfg, database, nil) + mux := http.NewServeMux() + server.RegisterRoutes(mux) + + // Send POST /targets/{id}/delete + req := httptest.NewRequest("POST", "/targets/1/delete", nil) + w := httptest.NewRecorder() + + mux.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String()) + } + + // Verify target table in response does not contain the deleted target + body := w.Body.String() + if strings.Contains(body, "Test Target") { + t.Errorf("expected target list in response to not contain 'Test Target', got:\n%s", body) + } + + // Verify DB is empty of targets and jobs + targets, err := database.GetAllTargets() + if err != nil || len(targets) != 0 { + t.Errorf("expected 0 targets in DB, got %v (err: %v)", targets, err) + } + + jobs, err := database.GetJobsWithAnalysis() + if err != nil || len(jobs) != 0 { + t.Errorf("expected 0 jobs in DB, got %v (err: %v)", jobs, err) + } +} + +func osWriteFile(path string, data []byte) error { + return os.WriteFile(path, data, 0644) +} diff --git a/internal/ui/template_layout.go b/internal/ui/template_layout.go index 41e6f5a..dd1ca76 100644 --- a/internal/ui/template_layout.go +++ b/internal/ui/template_layout.go @@ -137,6 +137,52 @@ const mainLayoutHTML = `{{ define "page" }} + +
+