feat: add target deletion functionality with cascaded removal of associated jobs and tasks

This commit is contained in:
2026-08-22 01:15:05 -07:00
parent af1ee1c9b3
commit 367929e7d0
9 changed files with 501 additions and 21 deletions
+31
View File
@@ -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 {
+84
View File
@@ -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)
}
+102
View File
@@ -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 &amp; 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 &amp; 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 &amp; 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 }}`
+17 -7
View File
@@ -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">
<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>
<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 }}
+4
View File
@@ -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") {