feat: add target deletion functionality with cascaded removal of associated jobs and tasks
This commit is contained in:
@@ -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"
|
||||
- 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"
|
||||
- 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"
|
||||
- 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
|
||||
+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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -137,6 +137,52 @@ const mainLayoutHTML = `{{ define "page" }}<!DOCTYPE html>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delete Target Confirmation Modal -->
|
||||
<div id="delete-target-modal" class="fixed inset-0 z-50 hidden flex items-center justify-center p-4" aria-modal="true" role="dialog">
|
||||
<div class="fixed inset-0 bg-slate-950/80 backdrop-blur-sm transition-opacity" onclick="closeDeleteTargetModal()"></div>
|
||||
<div class="relative w-full max-w-md bg-slate-900 border border-slate-700/80 rounded-2xl shadow-2xl shadow-black/80 p-6 text-slate-100 transform transition-all overflow-hidden z-10">
|
||||
<div class="absolute -top-24 -left-24 w-48 h-48 bg-rose-500/15 rounded-full blur-3xl pointer-events-none"></div>
|
||||
<div class="flex items-start gap-4">
|
||||
<div class="w-10 h-10 rounded-xl bg-rose-500/10 border border-rose-500/30 flex items-center justify-center flex-shrink-0 text-xl shadow-inner">
|
||||
⚠️
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<h3 class="text-base font-bold text-white leading-tight">Delete Target</h3>
|
||||
<p class="text-xs text-slate-400 mt-1">This is a bulk operation that cannot be undone.</p>
|
||||
</div>
|
||||
<button type="button" onclick="closeDeleteTargetModal()" class="text-slate-400 hover:text-white text-lg p-1 rounded hover:bg-slate-800 transition-colors">✕</button>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 p-3 rounded-xl bg-slate-950/60 border border-slate-800">
|
||||
<span class="text-[10px] uppercase font-bold text-slate-500 tracking-wider block">Target to remove:</span>
|
||||
<span id="delete-target-name" class="text-sm font-semibold text-rose-300 block mt-0.5 break-words"></span>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 text-xs text-slate-300 space-y-1.5 leading-relaxed">
|
||||
<p class="text-rose-400 font-medium">⚠️ What will be removed:</p>
|
||||
<ul class="list-disc list-inside text-[11px] text-slate-400 space-y-1 pl-1">
|
||||
<li>Target configuration and crawling settings</li>
|
||||
<li><strong class="text-slate-200">All discovered job postings</strong> from this target</li>
|
||||
<li>All associated AI qualification analyses & scores</li>
|
||||
<li>Any <strong class="text-slate-200">in-pipeline applications</strong> tracked for these jobs</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex items-center justify-end gap-3">
|
||||
<button type="button"
|
||||
onclick="closeDeleteTargetModal()"
|
||||
class="px-4 py-2 text-xs font-semibold text-slate-300 hover:text-white bg-slate-800 hover:bg-slate-700 rounded-lg border border-slate-700 transition-colors">
|
||||
Cancel
|
||||
</button>
|
||||
<button id="delete-target-confirm-btn"
|
||||
type="button"
|
||||
class="px-4 py-2 text-xs font-semibold text-white bg-rose-600 hover:bg-rose-500 rounded-lg shadow-lg shadow-rose-500/25 transition-all flex items-center gap-1.5 active:scale-95">
|
||||
<span>🗑️</span> Delete Target & All Posts
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Fixed Bottom Status & Action Bar -->
|
||||
<div id="bottom-bar"
|
||||
class="fixed bottom-0 left-0 right-0 z-40 bg-slate-900/95 backdrop-blur-md border-t border-slate-700/60 shadow-2xl shadow-black/50">
|
||||
@@ -214,6 +260,54 @@ const mainLayoutHTML = `{{ define "page" }}<!DOCTYPE html>
|
||||
document.getElementById('detail-modal').classList.add('hidden');
|
||||
}
|
||||
|
||||
// ── Delete Target Modal ──────────────────────────────────────────────
|
||||
let currentDeleteTargetId = null;
|
||||
|
||||
function openDeleteTargetModal(btn) {
|
||||
const id = btn.dataset.targetId;
|
||||
const name = btn.dataset.targetName || 'this target';
|
||||
currentDeleteTargetId = id;
|
||||
document.getElementById('delete-target-name').textContent = name;
|
||||
const confirmBtn = document.getElementById('delete-target-confirm-btn');
|
||||
confirmBtn.onclick = () => confirmDeleteTarget(id);
|
||||
document.getElementById('delete-target-modal').classList.remove('hidden');
|
||||
}
|
||||
|
||||
function closeDeleteTargetModal() {
|
||||
document.getElementById('delete-target-modal').classList.add('hidden');
|
||||
currentDeleteTargetId = null;
|
||||
}
|
||||
|
||||
function confirmDeleteTarget(id) {
|
||||
const confirmBtn = document.getElementById('delete-target-confirm-btn');
|
||||
confirmBtn.disabled = true;
|
||||
confirmBtn.textContent = 'Deleting…';
|
||||
|
||||
fetch('/targets/' + id + '/delete', { method: 'POST' })
|
||||
.then(res => {
|
||||
if (!res.ok) throw new Error('Failed to delete target');
|
||||
return res.text();
|
||||
})
|
||||
.then(html => {
|
||||
const targetContainer = document.getElementById('targets-container');
|
||||
if (targetContainer) targetContainer.innerHTML = html;
|
||||
closeDeleteTargetModal();
|
||||
// Refresh Jobs and Apply tables & stats since associated jobs were deleted
|
||||
const jobsContainer = document.getElementById('jobs-container');
|
||||
if (jobsContainer) htmx.trigger(jobsContainer, 'refresh');
|
||||
const applyContainer = document.getElementById('apply-container');
|
||||
if (applyContainer) htmx.trigger(applyContainer, 'refresh');
|
||||
refreshStatus();
|
||||
})
|
||||
.catch(err => {
|
||||
alert('Error deleting target: ' + err.message);
|
||||
})
|
||||
.finally(() => {
|
||||
confirmBtn.disabled = false;
|
||||
confirmBtn.innerHTML = '<span>🗑️</span> Delete Target & All Posts';
|
||||
});
|
||||
}
|
||||
|
||||
// ── Bottom Bar: Status Polling ────────────────────────────────────────
|
||||
function refreshStatus() {
|
||||
fetch('/api/status')
|
||||
@@ -369,6 +463,14 @@ const mainLayoutHTML = `{{ define "page" }}<!DOCTYPE html>
|
||||
if (e.target.id === 'apply-container' || e.target.closest?.('#apply-container'))
|
||||
initApplyTable();
|
||||
});
|
||||
|
||||
// Dismiss modals on Escape key
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
closeDetailModal();
|
||||
closeDeleteTargetModal();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>{{ end }}`
|
||||
|
||||
@@ -22,7 +22,8 @@ const targetsTabHTML = `{{ define "targetsTab" }}
|
||||
<select name="platform"
|
||||
class="px-3 py-2 text-xs rounded-lg bg-slate-800 border border-slate-700 text-white focus:outline-none focus:border-indigo-500">
|
||||
<option value="google_jobs">🔍 Google Jobs</option>
|
||||
<!-- More platforms will be added as crawlers are implemented -->
|
||||
<option value="apple">🍏 Apple</option>
|
||||
<option value="meta">♾️ Meta</option>
|
||||
</select>
|
||||
<button type="submit"
|
||||
class="px-4 py-2 bg-indigo-600 hover:bg-indigo-500 text-xs font-semibold text-white rounded-lg shadow-md transition-all">
|
||||
@@ -68,12 +69,21 @@ const targetListHTML = `{{ define "targets" }}
|
||||
{{ end }}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-right">
|
||||
<div class="flex items-center justify-end gap-1.5">
|
||||
<button hx-post="/targets/{{ .ID }}/toggle"
|
||||
hx-target="#targets-container"
|
||||
hx-swap="innerHTML"
|
||||
class="px-2.5 py-1 text-[11px] rounded bg-slate-800 hover:bg-slate-700 text-slate-200 border border-slate-700 transition-colors">
|
||||
{{ if .IsActive }}Pause{{ else }}Activate{{ end }}
|
||||
</button>
|
||||
<button type="button"
|
||||
data-target-id="{{ .ID }}"
|
||||
data-target-name="{{ .Name }}"
|
||||
onclick="openDeleteTargetModal(this)"
|
||||
class="px-2.5 py-1 text-[11px] rounded bg-rose-500/10 hover:bg-rose-500/20 text-rose-400 border border-rose-500/30 hover:border-rose-500/50 transition-colors flex items-center gap-1">
|
||||
<span>🗑️</span> Delete
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{{ else }}
|
||||
|
||||
@@ -43,6 +43,10 @@ var funcMap = template.FuncMap{
|
||||
return "💼 LinkedIn"
|
||||
} else if strings.Contains(p, "google") {
|
||||
return "🔍 Google Jobs"
|
||||
} else if strings.Contains(p, "apple") {
|
||||
return "🍏 Apple"
|
||||
} else if strings.Contains(p, "meta") || strings.Contains(p, "facebook") {
|
||||
return "♾️ Meta"
|
||||
} else if strings.Contains(p, "greenhouse") {
|
||||
return "🌿 Greenhouse"
|
||||
} else if strings.Contains(p, "lever") {
|
||||
|
||||
Reference in New Issue
Block a user