From 5ed74bb59ca7baf488db580e1241cb8e93ea820f Mon Sep 17 00:00:00 2001 From: Shaheer Sarfaraz <53654735+DaKheera47@users.noreply.github.com> Date: Wed, 18 Feb 2026 22:05:15 +0000 Subject: [PATCH] Tracer links (#174) * initial commit * format links right jobops.dakheera47.com/cv/shaheer-google-de * don't support legacy * remove phishing look * smaller links * readiness check in settings * rework UX * right col * pop a modal * modal improvements * show links * documentation disclaimer * fix(tracer-links): preserve descriptive resume link labels * fix(tracer-links): classify bot user agents before browser families * fix(tracer-links): reject non-http redirect destinations * fix(tracer-redirect): disable caching for tracked redirects * fix(origin): prefer canonical public base url over forwarded headers * fix(auth): protect tracer analytics routes behind basic auth * fix(ui): rename misleading tracer drilldown human metric * style(tests): format tracer-links invalid-destination assertion * fix(tests): prevent mocked fs from breaking sqlite data-dir resolution * style(docs): format versioned docs json for biome * fix(tests): mock tracer-links in pdf skills validation suite --- .env.example | 5 + docs-site/docs/features/reactive-resume.md | 24 +- docs-site/docs/features/settings.md | 20 + docs-site/docs/features/tracer-links.md | 151 ++++ .../workflows/find-jobs-and-apply-workflow.md | 7 +- .../version-0.1.23-sidebars.json | 9 +- docs-site/versions.json | 7 +- orchestrator/src/client/App.tsx | 2 + orchestrator/src/client/api/client.ts | 66 ++ .../components/JobDetailsEditDrawer.test.tsx | 40 ++ .../components/JobDetailsEditDrawer.tsx | 66 +- .../src/client/components/JobHeader.tsx | 13 + .../client/components/OnboardingGate.test.tsx | 4 +- .../components/TailoringEditor.test.tsx | 38 ++ .../discovered-panel/TailorMode.test.tsx | 12 + .../src/client/components/navigation.ts | 7 + .../tailoring/TailoringSections.tsx | 50 ++ .../tailoring/TailoringWorkspace.tsx | 45 +- .../components/tailoring/useTailoringDraft.ts | 15 + .../src/client/hooks/useTracerReadiness.ts | 101 +++ .../src/client/pages/SettingsPage.test.tsx | 15 + .../src/client/pages/SettingsPage.tsx | 34 + .../src/client/pages/TracerLinksPage.tsx | 644 ++++++++++++++++++ .../components/TracerLinksSettingsSection.tsx | 148 ++++ orchestrator/src/server/api/routes.ts | 2 + .../src/server/api/routes/jobs.test.ts | 134 ++++ orchestrator/src/server/api/routes/jobs.ts | 86 ++- .../server/api/routes/tracer-links.test.ts | 246 +++++++ .../src/server/api/routes/tracer-links.ts | 177 +++++ orchestrator/src/server/app.ts | 57 ++ orchestrator/src/server/db/migrate.ts | 46 +- orchestrator/src/server/db/schema.ts | 66 ++ .../src/server/pipeline/orchestrator.ts | 8 +- orchestrator/src/server/repositories/jobs.ts | 1 + .../server/repositories/tracer-links.test.ts | 92 +++ .../src/server/repositories/tracer-links.ts | 494 ++++++++++++++ .../services/pdf-skills-validation.test.ts | 13 + .../src/server/services/pdf-tailoring.test.ts | 41 ++ orchestrator/src/server/services/pdf.ts | 29 + .../src/server/services/tracer-links.test.ts | 306 +++++++++ .../src/server/services/tracer-links.ts | 622 +++++++++++++++++ .../src/server/tailoring-flow.test.ts | 8 + shared/src/testing/factories.ts | 1 + shared/src/types.ts | 101 +++ 44 files changed, 4025 insertions(+), 28 deletions(-) create mode 100644 docs-site/docs/features/tracer-links.md create mode 100644 orchestrator/src/client/hooks/useTracerReadiness.ts create mode 100644 orchestrator/src/client/pages/TracerLinksPage.tsx create mode 100644 orchestrator/src/client/pages/settings/components/TracerLinksSettingsSection.tsx create mode 100644 orchestrator/src/server/api/routes/tracer-links.test.ts create mode 100644 orchestrator/src/server/api/routes/tracer-links.ts create mode 100644 orchestrator/src/server/repositories/tracer-links.test.ts create mode 100644 orchestrator/src/server/repositories/tracer-links.ts create mode 100644 orchestrator/src/server/services/tracer-links.test.ts create mode 100644 orchestrator/src/server/services/tracer-links.ts diff --git a/.env.example b/.env.example index dd793bd..f88619d 100644 --- a/.env.example +++ b/.env.example @@ -19,6 +19,11 @@ RXRESUME_PASSWORD=your_password_here BASIC_AUTH_USER= BASIC_AUTH_PASSWORD= +# Public base URL used to generate tracer links when PDFs are created by +# background/pipeline runs (where request host cannot be inferred). +# Example: JOBOPS_PUBLIC_BASE_URL=https://jobops.example.com +JOBOPS_PUBLIC_BASE_URL= + # ============================================================================= # Gmail OAuth (Tracking Inbox) - optional # ============================================================================= diff --git a/docs-site/docs/features/reactive-resume.md b/docs-site/docs/features/reactive-resume.md index 4ba8fd2..fbded44 100644 --- a/docs-site/docs/features/reactive-resume.md +++ b/docs-site/docs/features/reactive-resume.md @@ -123,9 +123,27 @@ High-level flow: 1. Load selected base resume from RxResume. 2. Apply tailored summary/headline/skills. 3. Compute final visible projects from your selection rules. -4. Create temporary resume in RxResume. -5. Export PDF. -6. Delete temporary resume. +4. Optionally rewrite outbound links to tracer links (per-job toggle). +5. Create temporary resume in RxResume. +6. Export PDF. +7. Delete temporary resume. + +### Per-job tracer links + +Before generating a PDF, each job can enable/disable tracer links. + +- Disabled: original RxResume links remain unchanged. +- Enabled: eligible outbound links are rewritten to `https:///cv/-xx` (readable slug + 2-letter suffix). + +For background pipeline generation, configure: + +- `JOBOPS_PUBLIC_BASE_URL=https://your-host` + +Important: + +- tracer enablement is gated by readiness checks +- if public host verification fails, enable is blocked until host health is restored +- toggle changes apply on next PDF generation only ### What JobOps changes with AI diff --git a/docs-site/docs/features/settings.md b/docs-site/docs/features/settings.md index ad51acd..f0058da 100644 --- a/docs-site/docs/features/settings.md +++ b/docs-site/docs/features/settings.md @@ -18,6 +18,7 @@ It lets you configure: - Display and Ghostwriter defaults - Service credentials and basic auth - Reactive Resume project selection +- Tracer Links readiness verification - Backup and scoring rules - Data-clearing actions in the Danger Zone @@ -81,6 +82,19 @@ Settings gives you runtime overrides for the key parts of discovery, scoring, ta - Must-include projects - AI-selectable projects +### Tracer Links + +- Verify tracer readiness before enabling per-job tracing +- Shows current status (`Ready`, `Unavailable`, `Unconfigured`, or stale state) +- Displays the effective public base URL and last check time +- Provides **Verify now** for an on-demand health check + +Readiness requires: + +- a valid public JobOps base URL +- successful reachability of `/health` +- non-localhost/non-private host setup for public redirect usage + ### Environment & Accounts - Configure service accounts: @@ -163,6 +177,12 @@ curl -X POST "http://localhost:3001/api/backups" - Verify URL reachability from the server host. - Confirm auth expectations on the receiver side (including secret/bearer token). +### Tracer links cannot be enabled + +- Open **Settings → Tracer Links** and click **Verify now**. +- Ensure `JOBOPS_PUBLIC_BASE_URL` is set for background/pipeline usage. +- Ensure the configured host is publicly reachable and `/health` responds. + ## Related pages - [Reactive Resume](./reactive-resume) diff --git a/docs-site/docs/features/tracer-links.md b/docs-site/docs/features/tracer-links.md new file mode 100644 index 0000000..e374702 --- /dev/null +++ b/docs-site/docs/features/tracer-links.md @@ -0,0 +1,151 @@ +--- +id: tracer-links +title: Tracer Links +description: Track outbound resume-link clicks with per-job toggles and privacy-safe analytics. +sidebar_position: 8 +--- + +## What it is + +Tracer Links are per-job redirect links that are generated when a PDF is created. + +When enabled for a job, JobOps rewrites eligible outbound RxResume links to your JobOps host, then redirects to the original destination after recording a click event. + +Examples: + +- original: `https://github.com/yourname` +- traced: `https://jobops.dakheera47.com/cv/amazon-de` + +Format details: + +- path prefix is always `/cv/` +- token format is `-` +- `` is two lowercase letters (`a-z`) +- visible link text in the PDF is also updated to the traced URL + +## Why it exists + +Without tracer links, resume links are "fire and forget". + +Tracer links let you answer: + +- whether links in a specific job PDF were opened +- which destination links are being opened most +- rough human vs bot traffic split +- per-job and global engagement trends over time + +The feature is privacy-safe by design: + +- no raw IP is stored +- referrer host is stored (not full referrer URL) +- bot traffic is flagged and can be filtered in analytics + +## How to use it + +1. Open **Settings** and go to the **Tracer Links** section. +2. Click **Verify now** and confirm status is **Ready**. +3. Open a job in **Jobs**. +4. Enable **Tracer links for this job** in tailoring or job details. +5. Generate or regenerate the PDF. +6. Open **Tracer Links** in navigation to view: + - global totals + - top jobs and top links + - per-job drilldown by Job ID + +Important behavior: + +- Tracer links are **off by default** per job. +- Toggle changes apply on the **next PDF generation only**. +- Existing PDFs are not modified retroactively. +- Existing tracer URLs remain valid, even if a newer PDF generates new links. + +### Readiness and enable/disable behavior + +You can only turn tracer links **on** when readiness is healthy. + +Readiness checks: + +- a resolvable public base URL +- a successful health probe to `/health` +- a non-localhost/non-private host for public usage + +If readiness is unavailable, enable is blocked until verification passes. + +### Required background-run setting + +If PDFs are generated by background pipeline runs, set: + +```bash +JOBOPS_PUBLIC_BASE_URL=https://your-jobops-host +``` + +JobOps uses this URL when request host inference is not available. + +### URL uniqueness rules + +Tracer links are unique enough for tracking while still readable. + +- same job + same source path + same destination URL => token is reused +- same job + same source path + changed destination URL => new token +- old tokens continue to redirect (not retroactively deleted) + +### Risk and responsibility disclaimer + +Tracer links are redirect links. Some recruiters, companies, universities, or security tools may treat redirects as suspicious behavior and may whitelist, blacklist, filter, or flag these links as phishing-like. + +By enabling and using this feature, you accept full responsibility for any consequences that result from its use. Responsibility for policy, trust, and reputation outcomes sits with the user/operator of the instance, not with the app. + +## Common problems + +### I cannot enable tracer links + +Cause: + +- readiness is not **Ready** +- host is local/private or unreachable from the verifier + +Fix: + +- configure a real public host +- set `JOBOPS_PUBLIC_BASE_URL` for background flows +- make sure `/health` is reachable +- retry **Verify now** + +### Tracer links enabled but PDF generation fails + +Cause: + +- base URL cannot be resolved at generation time, or instance health is not reachable for that run + +Fix: + +- ensure `JOBOPS_PUBLIC_BASE_URL` is set correctly +- verify the deployment is publicly reachable +- regenerate the PDF + +### I enabled tracer links, but old PDF still has direct links + +Cause: + +- toggle changes only apply to newly generated PDFs + +Fix: + +- regenerate the PDF for that job + +### Analytics look inflated by scanners + +Cause: + +- link scanners and preview bots may open links automatically + +Fix: + +- use the **Include likely bots** filter in Tracer Links analytics + +## Related pages + +- [Settings](/docs/features/settings) +- [Reactive Resume](/docs/features/reactive-resume) +- [Find Jobs and Apply Workflow](/docs/workflows/find-jobs-and-apply-workflow) +- [Post-Application Tracking](/docs/features/post-application-tracking) diff --git a/docs-site/docs/workflows/find-jobs-and-apply-workflow.md b/docs-site/docs/workflows/find-jobs-and-apply-workflow.md index 779324a..2456bd6 100644 --- a/docs-site/docs/workflows/find-jobs-and-apply-workflow.md +++ b/docs-site/docs/workflows/find-jobs-and-apply-workflow.md @@ -57,8 +57,9 @@ These jobs already have tailored PDFs generated for the specific job description At this stage: 1. Open job details. -2. Download the tailored PDF. -3. Submit your application externally. +2. Optionally enable tracer links for that specific job. +3. Download the tailored PDF. +4. Submit your application externally. ### 5) Mark jobs as applied in JobOps @@ -85,6 +86,8 @@ Once a job is marked `applied`, it becomes part of: - Increase tailored-job count only after score thresholds feel calibrated. - Expect scraper runtime variance by source. - Keep resume/project context up to date so scoring/tailoring quality stays high. +- Use per-job tracer links when you want measurable outbound-link analytics. +- If you use tracer links, review the risk note in [Tracer Links](../features/tracer-links): some recipients/security tools may treat redirects as suspicious. ## Related pages diff --git a/docs-site/versioned_sidebars/version-0.1.23-sidebars.json b/docs-site/versioned_sidebars/version-0.1.23-sidebars.json index 94e7e54..2c966ba 100644 --- a/docs-site/versioned_sidebars/version-0.1.23-sidebars.json +++ b/docs-site/versioned_sidebars/version-0.1.23-sidebars.json @@ -60,17 +60,12 @@ { "type": "category", "label": "Troubleshooting", - "items": [ - "troubleshooting/common-problems" - ] + "items": ["troubleshooting/common-problems"] }, { "type": "category", "label": "Reference / FAQ", - "items": [ - "reference/faq", - "reference/documentation-style-guide" - ] + "items": ["reference/faq", "reference/documentation-style-guide"] } ] } diff --git a/docs-site/versions.json b/docs-site/versions.json index f631987..b43723e 100644 --- a/docs-site/versions.json +++ b/docs-site/versions.json @@ -1,6 +1 @@ -[ - "0.1.23", - "0.1.22", - "0.1.21", - "0.1.20" -] +["0.1.23", "0.1.22", "0.1.21", "0.1.20"] diff --git a/orchestrator/src/client/App.tsx b/orchestrator/src/client/App.tsx index df4eb14..c1b79a7 100644 --- a/orchestrator/src/client/App.tsx +++ b/orchestrator/src/client/App.tsx @@ -17,6 +17,7 @@ import { InProgressBoardPage } from "./pages/InProgressBoardPage"; import { JobPage } from "./pages/JobPage"; import { OrchestratorPage } from "./pages/OrchestratorPage"; import { SettingsPage } from "./pages/SettingsPage"; +import { TracerLinksPage } from "./pages/TracerLinksPage"; import { TrackingInboxPage } from "./pages/TrackingInboxPage"; import { VisaSponsorsPage } from "./pages/VisaSponsorsPage"; @@ -106,6 +107,7 @@ export const App: React.FC = () => { element={} /> } /> + } /> } /> } /> } /> diff --git a/orchestrator/src/client/api/client.ts b/orchestrator/src/client/api/client.ts index a66854d..59dfa91 100644 --- a/orchestrator/src/client/api/client.ts +++ b/orchestrator/src/client/api/client.ts @@ -24,6 +24,7 @@ import type { JobSource, JobsListResponse, JobsRevisionResponse, + JobTracerLinksResponse, ManualJobDraft, ManualJobFetchResponse, ManualJobInferenceResponse, @@ -39,6 +40,8 @@ import type { StageEvent, StageEventMetadata, StageTransitionTarget, + TracerAnalyticsResponse, + TracerReadinessResponse, ValidationResult, VisaSponsor, VisaSponsorSearchResponse, @@ -399,6 +402,69 @@ export async function updateJob( }); } +export async function getTracerAnalytics(options?: { + jobId?: string; + from?: number; + to?: number; + includeBots?: boolean; + limit?: number; +}): Promise { + const params = new URLSearchParams(); + if (options?.jobId) params.set("jobId", options.jobId); + if (typeof options?.from === "number") { + params.set("from", String(options.from)); + } + if (typeof options?.to === "number") { + params.set("to", String(options.to)); + } + if (typeof options?.includeBots === "boolean") { + params.set("includeBots", options.includeBots ? "1" : "0"); + } + if (typeof options?.limit === "number") { + params.set("limit", String(options.limit)); + } + + const query = params.toString(); + return fetchApi( + `/tracer-links/analytics${query ? `?${query}` : ""}`, + ); +} + +export async function getTracerReadiness(options?: { + force?: boolean; +}): Promise { + const params = new URLSearchParams(); + if (options?.force) params.set("force", "1"); + const query = params.toString(); + return fetchApi( + `/tracer-links/readiness${query ? `?${query}` : ""}`, + ); +} + +export async function getJobTracerLinks( + jobId: string, + options?: { + from?: number; + to?: number; + includeBots?: boolean; + }, +): Promise { + const params = new URLSearchParams(); + if (typeof options?.from === "number") { + params.set("from", String(options.from)); + } + if (typeof options?.to === "number") { + params.set("to", String(options.to)); + } + if (typeof options?.includeBots === "boolean") { + params.set("includeBots", options.includeBots ? "1" : "0"); + } + const query = params.toString(); + return fetchApi( + `/tracer-links/jobs/${encodeURIComponent(jobId)}${query ? `?${query}` : ""}`, + ); +} + async function streamSseEvents( endpoint: string, input: StreamSseInput, diff --git a/orchestrator/src/client/components/JobDetailsEditDrawer.test.tsx b/orchestrator/src/client/components/JobDetailsEditDrawer.test.tsx index 899f7b4..ccfc091 100644 --- a/orchestrator/src/client/components/JobDetailsEditDrawer.test.tsx +++ b/orchestrator/src/client/components/JobDetailsEditDrawer.test.tsx @@ -4,6 +4,7 @@ import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import type React from "react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import * as api from "../api"; +import { _resetTracerReadinessCache } from "../hooks/useTracerReadiness"; import { JobDetailsEditDrawer } from "./JobDetailsEditDrawer"; vi.mock("@/components/ui/sheet", () => ({ @@ -27,6 +28,7 @@ vi.mock("../api", () => ({ updateJob: vi.fn(), checkSponsor: vi.fn(), rescoreJob: vi.fn(), + getTracerReadiness: vi.fn(), })); vi.mock("sonner", () => ({ @@ -39,6 +41,16 @@ vi.mock("sonner", () => ({ describe("JobDetailsEditDrawer", () => { beforeEach(() => { vi.clearAllMocks(); + _resetTracerReadinessCache(); + vi.mocked(api.getTracerReadiness).mockResolvedValue({ + status: "ready", + canEnable: true, + publicBaseUrl: "https://my-jobops.example.com", + healthUrl: "https://my-jobops.example.com/health", + checkedAt: Date.now(), + lastSuccessAt: Date.now(), + reason: null, + }); }); it("saves details and reruns sponsor check when employer changes", async () => { @@ -138,4 +150,32 @@ describe("JobDetailsEditDrawer", () => { await waitFor(() => expect(api.rescoreJob).toHaveBeenCalledWith("job-1")); expect(onJobUpdated).toHaveBeenCalledTimes(2); }); + + it("persists tracer-links toggle with job updates", async () => { + const onJobUpdated = vi.fn().mockResolvedValue(undefined); + const onOpenChange = vi.fn(); + vi.mocked(api.updateJob).mockResolvedValue({} as Job); + + render( + , + ); + + await waitFor(() => expect(api.getTracerReadiness).toHaveBeenCalled()); + fireEvent.click(screen.getByLabelText("Enable tracer links for this job")); + fireEvent.click(screen.getByRole("button", { name: /save details/i })); + + await waitFor(() => + expect(api.updateJob).toHaveBeenCalledWith( + "job-1", + expect.objectContaining({ + tracerLinksEnabled: true, + }), + ), + ); + }); }); diff --git a/orchestrator/src/client/components/JobDetailsEditDrawer.tsx b/orchestrator/src/client/components/JobDetailsEditDrawer.tsx index cd32f6a..0e0bb6f 100644 --- a/orchestrator/src/client/components/JobDetailsEditDrawer.tsx +++ b/orchestrator/src/client/components/JobDetailsEditDrawer.tsx @@ -4,6 +4,7 @@ import type React from "react"; import { useEffect, useMemo, useState } from "react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; import { Input } from "@/components/ui/input"; import { Sheet, @@ -14,6 +15,7 @@ import { } from "@/components/ui/sheet"; import { Textarea } from "@/components/ui/textarea"; import * as api from "../api"; +import { useTracerReadiness } from "../hooks/useTracerReadiness"; interface JobDetailsEditDrawerProps { open: boolean; @@ -31,6 +33,7 @@ type JobDetailsDraft = { salary: string; deadline: string; jobDescription: string; + tracerLinksEnabled: boolean; }; const emptyDraft: JobDetailsDraft = { @@ -42,6 +45,7 @@ const emptyDraft: JobDetailsDraft = { salary: "", deadline: "", jobDescription: "", + tracerLinksEnabled: false, }; const normalizeOptional = (value: string): string | null => { @@ -60,6 +64,7 @@ const normalizeFromJob = (job: Job | null): JobDetailsDraft => { salary: job.salary ?? "", deadline: job.deadline ?? "", jobDescription: job.jobDescription ?? "", + tracerLinksEnabled: Boolean(job.tracerLinksEnabled), }; }; @@ -81,6 +86,8 @@ export const JobDetailsEditDrawer: React.FC = ({ const [draft, setDraft] = useState(emptyDraft); const [validationError, setValidationError] = useState(null); const [isSaving, setIsSaving] = useState(false); + const { readiness: tracerReadiness, isChecking: isTracerReadinessChecking } = + useTracerReadiness(); useEffect(() => { if (!open) return; @@ -90,6 +97,14 @@ export const JobDetailsEditDrawer: React.FC = ({ }, [job, open]); const hasJob = !!job; + const tracerCanEnable = Boolean(tracerReadiness?.canEnable); + const tracerEnableBlocked = !draft.tracerLinksEnabled && !tracerCanEnable; + const tracerEnableBlockedReason = + tracerReadiness?.canEnable === false + ? (tracerReadiness.reason ?? + "Tracer links are unavailable right now. Verify Tracer Links in Settings.") + : null; + const isDirty = useMemo(() => { if (!job) return false; const current = normalizeFromJob(job); @@ -101,7 +116,8 @@ export const JobDetailsEditDrawer: React.FC = ({ draft.location !== current.location || draft.salary !== current.salary || draft.deadline !== current.deadline || - draft.jobDescription !== current.jobDescription + draft.jobDescription !== current.jobDescription || + draft.tracerLinksEnabled !== current.tracerLinksEnabled ); }, [draft, job]); @@ -133,6 +149,17 @@ export const JobDetailsEditDrawer: React.FC = ({ setValidationError("Application URL must be a valid URL."); return; } + if ( + draft.tracerLinksEnabled && + !job.tracerLinksEnabled && + !tracerCanEnable + ) { + setValidationError( + tracerEnableBlockedReason ?? + "Tracer links are unavailable right now. Verify Tracer Links in Settings.", + ); + return; + } try { setValidationError(null); @@ -150,6 +177,7 @@ export const JobDetailsEditDrawer: React.FC = ({ salary: normalizeOptional(draft.salary), deadline: normalizeOptional(draft.deadline), jobDescription: normalizeOptional(draft.jobDescription), + tracerLinksEnabled: draft.tracerLinksEnabled, }); if (employerChanged) { @@ -281,6 +309,42 @@ export const JobDetailsEditDrawer: React.FC = ({ /> +
+ +

+ {isTracerReadinessChecking + ? "Checking tracer-link readiness..." + : "Applies on the next PDF generation. Existing PDFs are not modified."} +

+ {tracerEnableBlockedReason && !draft.tracerLinksEnabled ? ( +

+ Tracer links are unavailable: {tracerEnableBlockedReason} +

+ ) : null} +

+ No raw IP is stored. Analytics are privacy-safe and + anonymous. +

+
+