jobcompletion webhook
This commit is contained in:
@@ -24,6 +24,7 @@ NOTION_DATABASE_ID=
|
|||||||
# Optional: Webhook secret for n8n automation
|
# Optional: Webhook secret for n8n automation
|
||||||
WEBHOOK_SECRET=
|
WEBHOOK_SECRET=
|
||||||
PIPELINE_WEBHOOK_URL=
|
PIPELINE_WEBHOOK_URL=
|
||||||
|
JOB_COMPLETE_WEBHOOK_URL=
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# JobSpy (Indeed/LinkedIn scraping) - optional
|
# JobSpy (Indeed/LinkedIn scraping) - optional
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ NOTION_DATABASE_ID=
|
|||||||
# Webhook security (optional)
|
# Webhook security (optional)
|
||||||
WEBHOOK_SECRET=
|
WEBHOOK_SECRET=
|
||||||
PIPELINE_WEBHOOK_URL=
|
PIPELINE_WEBHOOK_URL=
|
||||||
|
JOB_COMPLETE_WEBHOOK_URL=
|
||||||
|
|
||||||
# Pipeline configuration
|
# Pipeline configuration
|
||||||
PIPELINE_TOP_N=10
|
PIPELINE_TOP_N=10
|
||||||
|
|||||||
@@ -101,6 +101,7 @@ export async function getSettings(): Promise<AppSettings> {
|
|||||||
export async function updateSettings(update: {
|
export async function updateSettings(update: {
|
||||||
model?: string | null
|
model?: string | null
|
||||||
pipelineWebhookUrl?: string | null
|
pipelineWebhookUrl?: string | null
|
||||||
|
jobCompleteWebhookUrl?: string | null
|
||||||
}): Promise<AppSettings> {
|
}): Promise<AppSettings> {
|
||||||
return fetchApi<AppSettings>('/settings', {
|
return fetchApi<AppSettings>('/settings', {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export const SettingsPage: React.FC = () => {
|
|||||||
const [settings, setSettings] = useState<AppSettings | null>(null)
|
const [settings, setSettings] = useState<AppSettings | null>(null)
|
||||||
const [modelDraft, setModelDraft] = useState("")
|
const [modelDraft, setModelDraft] = useState("")
|
||||||
const [pipelineWebhookUrlDraft, setPipelineWebhookUrlDraft] = useState("")
|
const [pipelineWebhookUrlDraft, setPipelineWebhookUrlDraft] = useState("")
|
||||||
|
const [jobCompleteWebhookUrlDraft, setJobCompleteWebhookUrlDraft] = useState("")
|
||||||
const [isSaving, setIsSaving] = useState(false)
|
const [isSaving, setIsSaving] = useState(false)
|
||||||
const [isLoading, setIsLoading] = useState(true)
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
|
|
||||||
@@ -29,6 +30,7 @@ export const SettingsPage: React.FC = () => {
|
|||||||
setSettings(data)
|
setSettings(data)
|
||||||
setModelDraft(data.overrideModel ?? "")
|
setModelDraft(data.overrideModel ?? "")
|
||||||
setPipelineWebhookUrlDraft(data.overridePipelineWebhookUrl ?? "")
|
setPipelineWebhookUrlDraft(data.overridePipelineWebhookUrl ?? "")
|
||||||
|
setJobCompleteWebhookUrlDraft(data.overrideJobCompleteWebhookUrl ?? "")
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
const message = error instanceof Error ? error.message : "Failed to load settings"
|
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 effectivePipelineWebhookUrl = settings?.pipelineWebhookUrl ?? ""
|
||||||
const defaultPipelineWebhookUrl = settings?.defaultPipelineWebhookUrl ?? ""
|
const defaultPipelineWebhookUrl = settings?.defaultPipelineWebhookUrl ?? ""
|
||||||
const overridePipelineWebhookUrl = settings?.overridePipelineWebhookUrl
|
const overridePipelineWebhookUrl = settings?.overridePipelineWebhookUrl
|
||||||
|
const effectiveJobCompleteWebhookUrl = settings?.jobCompleteWebhookUrl ?? ""
|
||||||
|
const defaultJobCompleteWebhookUrl = settings?.defaultJobCompleteWebhookUrl ?? ""
|
||||||
|
const overrideJobCompleteWebhookUrl = settings?.overrideJobCompleteWebhookUrl
|
||||||
|
|
||||||
const canSave = useMemo(() => {
|
const canSave = useMemo(() => {
|
||||||
if (!settings) return false
|
if (!settings) return false
|
||||||
@@ -57,8 +62,22 @@ export const SettingsPage: React.FC = () => {
|
|||||||
const current = (overrideModel ?? "").trim()
|
const current = (overrideModel ?? "").trim()
|
||||||
const nextWebhook = pipelineWebhookUrlDraft.trim()
|
const nextWebhook = pipelineWebhookUrlDraft.trim()
|
||||||
const currentWebhook = (overridePipelineWebhookUrl ?? "").trim()
|
const currentWebhook = (overridePipelineWebhookUrl ?? "").trim()
|
||||||
return next !== current || nextWebhook !== currentWebhook
|
const nextJobCompleteWebhook = jobCompleteWebhookUrlDraft.trim()
|
||||||
}, [modelDraft, overrideModel, settings, pipelineWebhookUrlDraft, overridePipelineWebhookUrl])
|
const currentJobCompleteWebhook = (overrideJobCompleteWebhookUrl ?? "").trim()
|
||||||
|
return (
|
||||||
|
next !== current ||
|
||||||
|
nextWebhook !== currentWebhook ||
|
||||||
|
nextJobCompleteWebhook !== currentJobCompleteWebhook
|
||||||
|
)
|
||||||
|
}, [
|
||||||
|
settings,
|
||||||
|
modelDraft,
|
||||||
|
pipelineWebhookUrlDraft,
|
||||||
|
jobCompleteWebhookUrlDraft,
|
||||||
|
overrideModel,
|
||||||
|
overridePipelineWebhookUrl,
|
||||||
|
overrideJobCompleteWebhookUrl,
|
||||||
|
])
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
if (!settings) return
|
if (!settings) return
|
||||||
@@ -66,13 +85,16 @@ export const SettingsPage: React.FC = () => {
|
|||||||
setIsSaving(true)
|
setIsSaving(true)
|
||||||
const trimmed = modelDraft.trim()
|
const trimmed = modelDraft.trim()
|
||||||
const webhookTrimmed = pipelineWebhookUrlDraft.trim()
|
const webhookTrimmed = pipelineWebhookUrlDraft.trim()
|
||||||
|
const jobCompleteTrimmed = jobCompleteWebhookUrlDraft.trim()
|
||||||
const updated = await api.updateSettings({
|
const updated = await api.updateSettings({
|
||||||
model: trimmed.length > 0 ? trimmed : null,
|
model: trimmed.length > 0 ? trimmed : null,
|
||||||
pipelineWebhookUrl: webhookTrimmed.length > 0 ? webhookTrimmed : null,
|
pipelineWebhookUrl: webhookTrimmed.length > 0 ? webhookTrimmed : null,
|
||||||
|
jobCompleteWebhookUrl: jobCompleteTrimmed.length > 0 ? jobCompleteTrimmed : null,
|
||||||
})
|
})
|
||||||
setSettings(updated)
|
setSettings(updated)
|
||||||
setModelDraft(updated.overrideModel ?? "")
|
setModelDraft(updated.overrideModel ?? "")
|
||||||
setPipelineWebhookUrlDraft(updated.overridePipelineWebhookUrl ?? "")
|
setPipelineWebhookUrlDraft(updated.overridePipelineWebhookUrl ?? "")
|
||||||
|
setJobCompleteWebhookUrlDraft(updated.overrideJobCompleteWebhookUrl ?? "")
|
||||||
toast.success("Settings saved")
|
toast.success("Settings saved")
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : "Failed to save settings"
|
const message = error instanceof Error ? error.message : "Failed to save settings"
|
||||||
@@ -85,10 +107,11 @@ export const SettingsPage: React.FC = () => {
|
|||||||
const handleReset = async () => {
|
const handleReset = async () => {
|
||||||
try {
|
try {
|
||||||
setIsSaving(true)
|
setIsSaving(true)
|
||||||
const updated = await api.updateSettings({ model: null, pipelineWebhookUrl: null })
|
const updated = await api.updateSettings({ model: null, pipelineWebhookUrl: null, jobCompleteWebhookUrl: null })
|
||||||
setSettings(updated)
|
setSettings(updated)
|
||||||
setModelDraft("")
|
setModelDraft("")
|
||||||
setPipelineWebhookUrlDraft("")
|
setPipelineWebhookUrlDraft("")
|
||||||
|
setJobCompleteWebhookUrlDraft("")
|
||||||
toast.success("Reset to default")
|
toast.success("Reset to default")
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : "Failed to reset settings"
|
const message = error instanceof Error ? error.message : "Failed to reset settings"
|
||||||
@@ -173,6 +196,40 @@ export const SettingsPage: React.FC = () => {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">Job Complete Webhook</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="text-sm font-medium">Job completion webhook URL</div>
|
||||||
|
<Input
|
||||||
|
value={jobCompleteWebhookUrlDraft}
|
||||||
|
onChange={(event) => setJobCompleteWebhookUrlDraft(event.target.value)}
|
||||||
|
placeholder={defaultJobCompleteWebhookUrl || "https://..."}
|
||||||
|
disabled={isLoading || isSaving}
|
||||||
|
/>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
When set, the server sends a POST when you mark a job as applied (includes the job description).
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<div className="grid gap-2 text-sm sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-muted-foreground">Effective</div>
|
||||||
|
<div className="break-words font-mono text-xs">{effectiveJobCompleteWebhookUrl || "—"}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-muted-foreground">Default (env)</div>
|
||||||
|
<div className="break-words font-mono text-xs">{defaultJobCompleteWebhookUrl || "—"}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<Button onClick={handleSave} disabled={isLoading || isSaving || !canSave}>
|
<Button onClick={handleSave} disabled={isLoading || isSaving || !canSave}>
|
||||||
{isSaving ? "Saving..." : "Save"}
|
{isSaving ? "Saving..." : "Save"}
|
||||||
|
|||||||
@@ -10,10 +10,38 @@ import * as settingsRepo from '../repositories/settings.js';
|
|||||||
import { runPipeline, processJob, getPipelineStatus, subscribeToProgress, getProgress } from '../pipeline/index.js';
|
import { runPipeline, processJob, getPipelineStatus, subscribeToProgress, getProgress } from '../pipeline/index.js';
|
||||||
import { createNotionEntry } from '../services/notion.js';
|
import { createNotionEntry } from '../services/notion.js';
|
||||||
import { clearDatabase } from '../db/clear.js';
|
import { clearDatabase } from '../db/clear.js';
|
||||||
import type { JobStatus, ApiResponse, JobsListResponse, PipelineStatusResponse } from '../../shared/types.js';
|
import type { Job, JobStatus, ApiResponse, JobsListResponse, PipelineStatusResponse } from '../../shared/types.js';
|
||||||
|
|
||||||
export const apiRouter = Router();
|
export const apiRouter = Router();
|
||||||
|
|
||||||
|
async function notifyJobCompleteWebhook(job: Job) {
|
||||||
|
const overrideWebhookUrl = await settingsRepo.getSetting('jobCompleteWebhookUrl')
|
||||||
|
const webhookUrl = (overrideWebhookUrl || process.env.JOB_COMPLETE_WEBHOOK_URL || '').trim()
|
||||||
|
if (!webhookUrl) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
|
||||||
|
const secret = process.env.WEBHOOK_SECRET
|
||||||
|
if (secret) headers.Authorization = `Bearer ${secret}`
|
||||||
|
|
||||||
|
const response = await fetch(webhookUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify({
|
||||||
|
event: 'job.completed',
|
||||||
|
sentAt: new Date().toISOString(),
|
||||||
|
job,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
console.warn(`⚠️ Job complete webhook POST failed (${response.status}): ${await response.text()}`)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('⚠️ Job complete webhook POST failed:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Jobs API
|
// Jobs API
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -145,6 +173,10 @@ apiRouter.post('/jobs/:id/apply', async (req: Request, res: Response) => {
|
|||||||
appliedAt,
|
appliedAt,
|
||||||
notionPageId: notionResult.pageId,
|
notionPageId: notionResult.pageId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (updatedJob) {
|
||||||
|
notifyJobCompleteWebhook(updatedJob).catch(console.warn)
|
||||||
|
}
|
||||||
|
|
||||||
res.json({ success: true, data: updatedJob });
|
res.json({ success: true, data: updatedJob });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -188,6 +220,10 @@ apiRouter.get('/settings', async (_req: Request, res: Response) => {
|
|||||||
const defaultPipelineWebhookUrl = process.env.PIPELINE_WEBHOOK_URL || process.env.WEBHOOK_URL || '';
|
const defaultPipelineWebhookUrl = process.env.PIPELINE_WEBHOOK_URL || process.env.WEBHOOK_URL || '';
|
||||||
const pipelineWebhookUrl = overridePipelineWebhookUrl || defaultPipelineWebhookUrl;
|
const pipelineWebhookUrl = overridePipelineWebhookUrl || defaultPipelineWebhookUrl;
|
||||||
|
|
||||||
|
const overrideJobCompleteWebhookUrl = await settingsRepo.getSetting('jobCompleteWebhookUrl');
|
||||||
|
const defaultJobCompleteWebhookUrl = process.env.JOB_COMPLETE_WEBHOOK_URL || '';
|
||||||
|
const jobCompleteWebhookUrl = overrideJobCompleteWebhookUrl || defaultJobCompleteWebhookUrl;
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
data: {
|
data: {
|
||||||
@@ -197,6 +233,9 @@ apiRouter.get('/settings', async (_req: Request, res: Response) => {
|
|||||||
pipelineWebhookUrl,
|
pipelineWebhookUrl,
|
||||||
defaultPipelineWebhookUrl,
|
defaultPipelineWebhookUrl,
|
||||||
overridePipelineWebhookUrl,
|
overridePipelineWebhookUrl,
|
||||||
|
jobCompleteWebhookUrl,
|
||||||
|
defaultJobCompleteWebhookUrl,
|
||||||
|
overrideJobCompleteWebhookUrl,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -208,6 +247,7 @@ apiRouter.get('/settings', async (_req: Request, res: Response) => {
|
|||||||
const updateSettingsSchema = z.object({
|
const updateSettingsSchema = z.object({
|
||||||
model: z.string().trim().min(1).max(200).nullable().optional(),
|
model: z.string().trim().min(1).max(200).nullable().optional(),
|
||||||
pipelineWebhookUrl: z.string().trim().min(1).max(2000).nullable().optional(),
|
pipelineWebhookUrl: z.string().trim().min(1).max(2000).nullable().optional(),
|
||||||
|
jobCompleteWebhookUrl: z.string().trim().min(1).max(2000).nullable().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -227,6 +267,11 @@ apiRouter.patch('/settings', async (req: Request, res: Response) => {
|
|||||||
await settingsRepo.setSetting('pipelineWebhookUrl', pipelineWebhookUrl);
|
await settingsRepo.setSetting('pipelineWebhookUrl', pipelineWebhookUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ('jobCompleteWebhookUrl' in input) {
|
||||||
|
const webhookUrl = input.jobCompleteWebhookUrl ?? null;
|
||||||
|
await settingsRepo.setSetting('jobCompleteWebhookUrl', webhookUrl);
|
||||||
|
}
|
||||||
|
|
||||||
const overrideModel = await settingsRepo.getSetting('model');
|
const overrideModel = await settingsRepo.getSetting('model');
|
||||||
const defaultModel = process.env.MODEL || 'openai/gpt-4o-mini';
|
const defaultModel = process.env.MODEL || 'openai/gpt-4o-mini';
|
||||||
const model = overrideModel || defaultModel;
|
const model = overrideModel || defaultModel;
|
||||||
@@ -235,6 +280,10 @@ apiRouter.patch('/settings', async (req: Request, res: Response) => {
|
|||||||
const defaultPipelineWebhookUrl = process.env.PIPELINE_WEBHOOK_URL || process.env.WEBHOOK_URL || '';
|
const defaultPipelineWebhookUrl = process.env.PIPELINE_WEBHOOK_URL || process.env.WEBHOOK_URL || '';
|
||||||
const pipelineWebhookUrl = overridePipelineWebhookUrl || defaultPipelineWebhookUrl;
|
const pipelineWebhookUrl = overridePipelineWebhookUrl || defaultPipelineWebhookUrl;
|
||||||
|
|
||||||
|
const overrideJobCompleteWebhookUrl = await settingsRepo.getSetting('jobCompleteWebhookUrl');
|
||||||
|
const defaultJobCompleteWebhookUrl = process.env.JOB_COMPLETE_WEBHOOK_URL || '';
|
||||||
|
const jobCompleteWebhookUrl = overrideJobCompleteWebhookUrl || defaultJobCompleteWebhookUrl;
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
data: {
|
data: {
|
||||||
@@ -244,6 +293,9 @@ apiRouter.patch('/settings', async (req: Request, res: Response) => {
|
|||||||
pipelineWebhookUrl,
|
pipelineWebhookUrl,
|
||||||
defaultPipelineWebhookUrl,
|
defaultPipelineWebhookUrl,
|
||||||
overridePipelineWebhookUrl,
|
overridePipelineWebhookUrl,
|
||||||
|
jobCompleteWebhookUrl,
|
||||||
|
defaultJobCompleteWebhookUrl,
|
||||||
|
overrideJobCompleteWebhookUrl,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ const { settings } = schema
|
|||||||
|
|
||||||
export type SettingKey = 'model'
|
export type SettingKey = 'model'
|
||||||
| 'pipelineWebhookUrl'
|
| 'pipelineWebhookUrl'
|
||||||
|
| 'jobCompleteWebhookUrl'
|
||||||
|
|
||||||
export async function getSetting(key: SettingKey): Promise<string | null> {
|
export async function getSetting(key: SettingKey): Promise<string | null> {
|
||||||
const [row] = await db.select().from(settings).where(eq(settings.key, key))
|
const [row] = await db.select().from(settings).where(eq(settings.key, key))
|
||||||
|
|||||||
@@ -179,4 +179,7 @@ export interface AppSettings {
|
|||||||
pipelineWebhookUrl: string;
|
pipelineWebhookUrl: string;
|
||||||
defaultPipelineWebhookUrl: string;
|
defaultPipelineWebhookUrl: string;
|
||||||
overridePipelineWebhookUrl: string | null;
|
overridePipelineWebhookUrl: string | null;
|
||||||
|
jobCompleteWebhookUrl: string;
|
||||||
|
defaultJobCompleteWebhookUrl: string;
|
||||||
|
overrideJobCompleteWebhookUrl: string | null;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user