diff --git a/.env.example b/.env.example index 68f8d97..8598d5e 100644 --- a/.env.example +++ b/.env.example @@ -24,6 +24,7 @@ NOTION_DATABASE_ID= # Optional: Webhook secret for n8n automation WEBHOOK_SECRET= PIPELINE_WEBHOOK_URL= +JOB_COMPLETE_WEBHOOK_URL= # ============================================================================= # JobSpy (Indeed/LinkedIn scraping) - optional diff --git a/orchestrator/.env.example b/orchestrator/.env.example index a92ecc4..a12357a 100644 --- a/orchestrator/.env.example +++ b/orchestrator/.env.example @@ -12,6 +12,7 @@ NOTION_DATABASE_ID= # Webhook security (optional) WEBHOOK_SECRET= PIPELINE_WEBHOOK_URL= +JOB_COMPLETE_WEBHOOK_URL= # Pipeline configuration PIPELINE_TOP_N=10 diff --git a/orchestrator/src/client/api/client.ts b/orchestrator/src/client/api/client.ts index cef2bf9..b01f939 100644 --- a/orchestrator/src/client/api/client.ts +++ b/orchestrator/src/client/api/client.ts @@ -101,6 +101,7 @@ export async function getSettings(): Promise { export async function updateSettings(update: { model?: string | null pipelineWebhookUrl?: string | null + jobCompleteWebhookUrl?: string | null }): Promise { return fetchApi('/settings', { method: 'PATCH', diff --git a/orchestrator/src/client/pages/SettingsPage.tsx b/orchestrator/src/client/pages/SettingsPage.tsx index d2c4690..92b34bc 100644 --- a/orchestrator/src/client/pages/SettingsPage.tsx +++ b/orchestrator/src/client/pages/SettingsPage.tsx @@ -16,6 +16,7 @@ export const SettingsPage: React.FC = () => { const [settings, setSettings] = useState(null) const [modelDraft, setModelDraft] = useState("") const [pipelineWebhookUrlDraft, setPipelineWebhookUrlDraft] = useState("") + const [jobCompleteWebhookUrlDraft, setJobCompleteWebhookUrlDraft] = useState("") const [isSaving, setIsSaving] = useState(false) const [isLoading, setIsLoading] = useState(true) @@ -29,6 +30,7 @@ export const SettingsPage: React.FC = () => { setSettings(data) setModelDraft(data.overrideModel ?? "") setPipelineWebhookUrlDraft(data.overridePipelineWebhookUrl ?? "") + setJobCompleteWebhookUrlDraft(data.overrideJobCompleteWebhookUrl ?? "") }) .catch((error) => { const message = error instanceof Error ? error.message : "Failed to load settings" @@ -50,6 +52,9 @@ export const SettingsPage: React.FC = () => { const effectivePipelineWebhookUrl = settings?.pipelineWebhookUrl ?? "" const defaultPipelineWebhookUrl = settings?.defaultPipelineWebhookUrl ?? "" const overridePipelineWebhookUrl = settings?.overridePipelineWebhookUrl + const effectiveJobCompleteWebhookUrl = settings?.jobCompleteWebhookUrl ?? "" + const defaultJobCompleteWebhookUrl = settings?.defaultJobCompleteWebhookUrl ?? "" + const overrideJobCompleteWebhookUrl = settings?.overrideJobCompleteWebhookUrl const canSave = useMemo(() => { if (!settings) return false @@ -57,8 +62,22 @@ export const SettingsPage: React.FC = () => { const current = (overrideModel ?? "").trim() const nextWebhook = pipelineWebhookUrlDraft.trim() const currentWebhook = (overridePipelineWebhookUrl ?? "").trim() - return next !== current || nextWebhook !== currentWebhook - }, [modelDraft, overrideModel, settings, pipelineWebhookUrlDraft, overridePipelineWebhookUrl]) + const nextJobCompleteWebhook = jobCompleteWebhookUrlDraft.trim() + const currentJobCompleteWebhook = (overrideJobCompleteWebhookUrl ?? "").trim() + return ( + next !== current || + nextWebhook !== currentWebhook || + nextJobCompleteWebhook !== currentJobCompleteWebhook + ) + }, [ + settings, + modelDraft, + pipelineWebhookUrlDraft, + jobCompleteWebhookUrlDraft, + overrideModel, + overridePipelineWebhookUrl, + overrideJobCompleteWebhookUrl, + ]) const handleSave = async () => { if (!settings) return @@ -66,13 +85,16 @@ export const SettingsPage: React.FC = () => { setIsSaving(true) const trimmed = modelDraft.trim() const webhookTrimmed = pipelineWebhookUrlDraft.trim() + const jobCompleteTrimmed = jobCompleteWebhookUrlDraft.trim() const updated = await api.updateSettings({ model: trimmed.length > 0 ? trimmed : null, pipelineWebhookUrl: webhookTrimmed.length > 0 ? webhookTrimmed : null, + jobCompleteWebhookUrl: jobCompleteTrimmed.length > 0 ? jobCompleteTrimmed : null, }) setSettings(updated) setModelDraft(updated.overrideModel ?? "") setPipelineWebhookUrlDraft(updated.overridePipelineWebhookUrl ?? "") + setJobCompleteWebhookUrlDraft(updated.overrideJobCompleteWebhookUrl ?? "") toast.success("Settings saved") } catch (error) { const message = error instanceof Error ? error.message : "Failed to save settings" @@ -85,10 +107,11 @@ export const SettingsPage: React.FC = () => { const handleReset = async () => { try { setIsSaving(true) - const updated = await api.updateSettings({ model: null, pipelineWebhookUrl: null }) + const updated = await api.updateSettings({ model: null, pipelineWebhookUrl: null, jobCompleteWebhookUrl: null }) setSettings(updated) setModelDraft("") setPipelineWebhookUrlDraft("") + setJobCompleteWebhookUrlDraft("") toast.success("Reset to default") } catch (error) { const message = error instanceof Error ? error.message : "Failed to reset settings" @@ -173,6 +196,40 @@ export const SettingsPage: React.FC = () => { + + + Job Complete Webhook + + + +
+
Job completion webhook URL
+ setJobCompleteWebhookUrlDraft(event.target.value)} + placeholder={defaultJobCompleteWebhookUrl || "https://..."} + disabled={isLoading || isSaving} + /> +
+ When set, the server sends a POST when you mark a job as applied (includes the job description). +
+
+ + + +
+
+
Effective
+
{effectiveJobCompleteWebhookUrl || "—"}
+
+
+
Default (env)
+
{defaultJobCompleteWebhookUrl || "—"}
+
+
+
+
+