feat: add target deletion functionality with cascaded removal of associated jobs and tasks
This commit is contained in:
+81
-2
@@ -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 := `
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user