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
This commit is contained in:
Shaheer Sarfaraz 2026-02-18 22:05:15 +00:00 committed by GitHub
parent 1146d065f0
commit 5ed74bb59c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
44 changed files with 4025 additions and 28 deletions

View File

@ -19,6 +19,11 @@ RXRESUME_PASSWORD=your_password_here
BASIC_AUTH_USER= BASIC_AUTH_USER=
BASIC_AUTH_PASSWORD= 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 # Gmail OAuth (Tracking Inbox) - optional
# ============================================================================= # =============================================================================

View File

@ -123,9 +123,27 @@ High-level flow:
1. Load selected base resume from RxResume. 1. Load selected base resume from RxResume.
2. Apply tailored summary/headline/skills. 2. Apply tailored summary/headline/skills.
3. Compute final visible projects from your selection rules. 3. Compute final visible projects from your selection rules.
4. Create temporary resume in RxResume. 4. Optionally rewrite outbound links to tracer links (per-job toggle).
5. Export PDF. 5. Create temporary resume in RxResume.
6. Delete temporary resume. 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://<your-host>/cv/<company>-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 ### What JobOps changes with AI

View File

@ -18,6 +18,7 @@ It lets you configure:
- Display and Ghostwriter defaults - Display and Ghostwriter defaults
- Service credentials and basic auth - Service credentials and basic auth
- Reactive Resume project selection - Reactive Resume project selection
- Tracer Links readiness verification
- Backup and scoring rules - Backup and scoring rules
- Data-clearing actions in the Danger Zone - 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 - Must-include projects
- AI-selectable 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 `<public-base-url>/health`
- non-localhost/non-private host setup for public redirect usage
### Environment & Accounts ### Environment & Accounts
- Configure service accounts: - Configure service accounts:
@ -163,6 +177,12 @@ curl -X POST "http://localhost:3001/api/backups"
- Verify URL reachability from the server host. - Verify URL reachability from the server host.
- Confirm auth expectations on the receiver side (including secret/bearer token). - 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 ## Related pages
- [Reactive Resume](./reactive-resume) - [Reactive Resume](./reactive-resume)

View File

@ -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 `<company-slug>-<xx>`
- `<xx>` 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 `<public-base-url>/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 `<public-base-url>/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)

View File

@ -57,8 +57,9 @@ These jobs already have tailored PDFs generated for the specific job description
At this stage: At this stage:
1. Open job details. 1. Open job details.
2. Download the tailored PDF. 2. Optionally enable tracer links for that specific job.
3. Submit your application externally. 3. Download the tailored PDF.
4. Submit your application externally.
### 5) Mark jobs as applied in JobOps ### 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. - Increase tailored-job count only after score thresholds feel calibrated.
- Expect scraper runtime variance by source. - Expect scraper runtime variance by source.
- Keep resume/project context up to date so scoring/tailoring quality stays high. - 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 ## Related pages

View File

@ -60,17 +60,12 @@
{ {
"type": "category", "type": "category",
"label": "Troubleshooting", "label": "Troubleshooting",
"items": [ "items": ["troubleshooting/common-problems"]
"troubleshooting/common-problems"
]
}, },
{ {
"type": "category", "type": "category",
"label": "Reference / FAQ", "label": "Reference / FAQ",
"items": [ "items": ["reference/faq", "reference/documentation-style-guide"]
"reference/faq",
"reference/documentation-style-guide"
]
} }
] ]
} }

View File

@ -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"
]

View File

@ -17,6 +17,7 @@ import { InProgressBoardPage } from "./pages/InProgressBoardPage";
import { JobPage } from "./pages/JobPage"; import { JobPage } from "./pages/JobPage";
import { OrchestratorPage } from "./pages/OrchestratorPage"; import { OrchestratorPage } from "./pages/OrchestratorPage";
import { SettingsPage } from "./pages/SettingsPage"; import { SettingsPage } from "./pages/SettingsPage";
import { TracerLinksPage } from "./pages/TracerLinksPage";
import { TrackingInboxPage } from "./pages/TrackingInboxPage"; import { TrackingInboxPage } from "./pages/TrackingInboxPage";
import { VisaSponsorsPage } from "./pages/VisaSponsorsPage"; import { VisaSponsorsPage } from "./pages/VisaSponsorsPage";
@ -106,6 +107,7 @@ export const App: React.FC = () => {
element={<InProgressBoardPage />} element={<InProgressBoardPage />}
/> />
<Route path="/settings" element={<SettingsPage />} /> <Route path="/settings" element={<SettingsPage />} />
<Route path="/tracer-links" element={<TracerLinksPage />} />
<Route path="/visa-sponsors" element={<VisaSponsorsPage />} /> <Route path="/visa-sponsors" element={<VisaSponsorsPage />} />
<Route path="/tracking-inbox" element={<TrackingInboxPage />} /> <Route path="/tracking-inbox" element={<TrackingInboxPage />} />
<Route path="/jobs/:tab" element={<OrchestratorPage />} /> <Route path="/jobs/:tab" element={<OrchestratorPage />} />

View File

@ -24,6 +24,7 @@ import type {
JobSource, JobSource,
JobsListResponse, JobsListResponse,
JobsRevisionResponse, JobsRevisionResponse,
JobTracerLinksResponse,
ManualJobDraft, ManualJobDraft,
ManualJobFetchResponse, ManualJobFetchResponse,
ManualJobInferenceResponse, ManualJobInferenceResponse,
@ -39,6 +40,8 @@ import type {
StageEvent, StageEvent,
StageEventMetadata, StageEventMetadata,
StageTransitionTarget, StageTransitionTarget,
TracerAnalyticsResponse,
TracerReadinessResponse,
ValidationResult, ValidationResult,
VisaSponsor, VisaSponsor,
VisaSponsorSearchResponse, 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<TracerAnalyticsResponse> {
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<TracerAnalyticsResponse>(
`/tracer-links/analytics${query ? `?${query}` : ""}`,
);
}
export async function getTracerReadiness(options?: {
force?: boolean;
}): Promise<TracerReadinessResponse> {
const params = new URLSearchParams();
if (options?.force) params.set("force", "1");
const query = params.toString();
return fetchApi<TracerReadinessResponse>(
`/tracer-links/readiness${query ? `?${query}` : ""}`,
);
}
export async function getJobTracerLinks(
jobId: string,
options?: {
from?: number;
to?: number;
includeBots?: boolean;
},
): Promise<JobTracerLinksResponse> {
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<JobTracerLinksResponse>(
`/tracer-links/jobs/${encodeURIComponent(jobId)}${query ? `?${query}` : ""}`,
);
}
async function streamSseEvents<TEvent>( async function streamSseEvents<TEvent>(
endpoint: string, endpoint: string,
input: StreamSseInput, input: StreamSseInput,

View File

@ -4,6 +4,7 @@ import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import type React from "react"; import type React from "react";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import * as api from "../api"; import * as api from "../api";
import { _resetTracerReadinessCache } from "../hooks/useTracerReadiness";
import { JobDetailsEditDrawer } from "./JobDetailsEditDrawer"; import { JobDetailsEditDrawer } from "./JobDetailsEditDrawer";
vi.mock("@/components/ui/sheet", () => ({ vi.mock("@/components/ui/sheet", () => ({
@ -27,6 +28,7 @@ vi.mock("../api", () => ({
updateJob: vi.fn(), updateJob: vi.fn(),
checkSponsor: vi.fn(), checkSponsor: vi.fn(),
rescoreJob: vi.fn(), rescoreJob: vi.fn(),
getTracerReadiness: vi.fn(),
})); }));
vi.mock("sonner", () => ({ vi.mock("sonner", () => ({
@ -39,6 +41,16 @@ vi.mock("sonner", () => ({
describe("JobDetailsEditDrawer", () => { describe("JobDetailsEditDrawer", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); 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 () => { 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")); await waitFor(() => expect(api.rescoreJob).toHaveBeenCalledWith("job-1"));
expect(onJobUpdated).toHaveBeenCalledTimes(2); 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(
<JobDetailsEditDrawer
open
onOpenChange={onOpenChange}
job={createJob({ tracerLinksEnabled: false })}
onJobUpdated={onJobUpdated}
/>,
);
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,
}),
),
);
});
}); });

View File

@ -4,6 +4,7 @@ import type React from "react";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { import {
Sheet, Sheet,
@ -14,6 +15,7 @@ import {
} from "@/components/ui/sheet"; } from "@/components/ui/sheet";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import * as api from "../api"; import * as api from "../api";
import { useTracerReadiness } from "../hooks/useTracerReadiness";
interface JobDetailsEditDrawerProps { interface JobDetailsEditDrawerProps {
open: boolean; open: boolean;
@ -31,6 +33,7 @@ type JobDetailsDraft = {
salary: string; salary: string;
deadline: string; deadline: string;
jobDescription: string; jobDescription: string;
tracerLinksEnabled: boolean;
}; };
const emptyDraft: JobDetailsDraft = { const emptyDraft: JobDetailsDraft = {
@ -42,6 +45,7 @@ const emptyDraft: JobDetailsDraft = {
salary: "", salary: "",
deadline: "", deadline: "",
jobDescription: "", jobDescription: "",
tracerLinksEnabled: false,
}; };
const normalizeOptional = (value: string): string | null => { const normalizeOptional = (value: string): string | null => {
@ -60,6 +64,7 @@ const normalizeFromJob = (job: Job | null): JobDetailsDraft => {
salary: job.salary ?? "", salary: job.salary ?? "",
deadline: job.deadline ?? "", deadline: job.deadline ?? "",
jobDescription: job.jobDescription ?? "", jobDescription: job.jobDescription ?? "",
tracerLinksEnabled: Boolean(job.tracerLinksEnabled),
}; };
}; };
@ -81,6 +86,8 @@ export const JobDetailsEditDrawer: React.FC<JobDetailsEditDrawerProps> = ({
const [draft, setDraft] = useState<JobDetailsDraft>(emptyDraft); const [draft, setDraft] = useState<JobDetailsDraft>(emptyDraft);
const [validationError, setValidationError] = useState<string | null>(null); const [validationError, setValidationError] = useState<string | null>(null);
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
const { readiness: tracerReadiness, isChecking: isTracerReadinessChecking } =
useTracerReadiness();
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
@ -90,6 +97,14 @@ export const JobDetailsEditDrawer: React.FC<JobDetailsEditDrawerProps> = ({
}, [job, open]); }, [job, open]);
const hasJob = !!job; 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(() => { const isDirty = useMemo(() => {
if (!job) return false; if (!job) return false;
const current = normalizeFromJob(job); const current = normalizeFromJob(job);
@ -101,7 +116,8 @@ export const JobDetailsEditDrawer: React.FC<JobDetailsEditDrawerProps> = ({
draft.location !== current.location || draft.location !== current.location ||
draft.salary !== current.salary || draft.salary !== current.salary ||
draft.deadline !== current.deadline || draft.deadline !== current.deadline ||
draft.jobDescription !== current.jobDescription draft.jobDescription !== current.jobDescription ||
draft.tracerLinksEnabled !== current.tracerLinksEnabled
); );
}, [draft, job]); }, [draft, job]);
@ -133,6 +149,17 @@ export const JobDetailsEditDrawer: React.FC<JobDetailsEditDrawerProps> = ({
setValidationError("Application URL must be a valid URL."); setValidationError("Application URL must be a valid URL.");
return; return;
} }
if (
draft.tracerLinksEnabled &&
!job.tracerLinksEnabled &&
!tracerCanEnable
) {
setValidationError(
tracerEnableBlockedReason ??
"Tracer links are unavailable right now. Verify Tracer Links in Settings.",
);
return;
}
try { try {
setValidationError(null); setValidationError(null);
@ -150,6 +177,7 @@ export const JobDetailsEditDrawer: React.FC<JobDetailsEditDrawerProps> = ({
salary: normalizeOptional(draft.salary), salary: normalizeOptional(draft.salary),
deadline: normalizeOptional(draft.deadline), deadline: normalizeOptional(draft.deadline),
jobDescription: normalizeOptional(draft.jobDescription), jobDescription: normalizeOptional(draft.jobDescription),
tracerLinksEnabled: draft.tracerLinksEnabled,
}); });
if (employerChanged) { if (employerChanged) {
@ -281,6 +309,42 @@ export const JobDetailsEditDrawer: React.FC<JobDetailsEditDrawerProps> = ({
/> />
</div> </div>
<div className="mt-3 rounded-lg border border-border/60 bg-muted/10 px-3 py-3">
<label
htmlFor="edit-tracer-links-enabled"
className="flex cursor-pointer items-center gap-3"
>
<Checkbox
id="edit-tracer-links-enabled"
checked={draft.tracerLinksEnabled}
onCheckedChange={(checked) =>
setDraft((prev) => ({
...prev,
tracerLinksEnabled: Boolean(checked),
}))
}
disabled={isSaving || tracerEnableBlocked}
/>
<span className="text-sm font-medium">
Enable tracer links for this job
</span>
</label>
<p className="mt-2 text-xs text-muted-foreground">
{isTracerReadinessChecking
? "Checking tracer-link readiness..."
: "Applies on the next PDF generation. Existing PDFs are not modified."}
</p>
{tracerEnableBlockedReason && !draft.tracerLinksEnabled ? (
<p className="mt-2 text-xs text-destructive">
Tracer links are unavailable: {tracerEnableBlockedReason}
</p>
) : null}
<p className="mt-2 text-xs text-muted-foreground/80">
No raw IP is stored. Analytics are privacy-safe and
anonymous.
</p>
</div>
<div className="mt-3 space-y-1"> <div className="mt-3 space-y-1">
<label <label
htmlFor="edit-job-description" htmlFor="edit-job-description"

View File

@ -45,6 +45,18 @@ const StatusPill: React.FC<{ status: JobStatus }> = ({ status }) => {
); );
}; };
const TracerPill: React.FC<{ enabled: boolean }> = ({ enabled }) => (
<span className="inline-flex items-center gap-1.5 text-[10px] font-medium uppercase tracking-wide text-muted-foreground/80">
<span
className={cn(
"h-1.5 w-1.5 rounded-full opacity-80",
enabled ? "bg-violet-500" : "bg-slate-500",
)}
/>
{enabled ? "Tracer On" : "Tracer Off"}
</span>
);
const ScoreMeter: React.FC<{ score: number | null }> = ({ score }) => { const ScoreMeter: React.FC<{ score: number | null }> = ({ score }) => {
if (score == null) { if (score == null) {
return <span className="text-[10px] text-muted-foreground/60">-</span>; return <span className="text-[10px] text-muted-foreground/60">-</span>;
@ -256,6 +268,7 @@ export const JobHeader: React.FC<JobHeaderProps> = ({
<div className="flex items-center justify-between gap-2 py-1 border-y border-border/30"> <div className="flex items-center justify-between gap-2 py-1 border-y border-border/30">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<StatusPill status={job.status} /> <StatusPill status={job.status} />
<TracerPill enabled={job.tracerLinksEnabled} />
{showSponsorInfo && ( {showSponsorInfo && (
<SponsorPill <SponsorPill
score={job.sponsorMatchScore} score={job.sponsorMatchScore}

View File

@ -175,7 +175,9 @@ describe("OnboardingGate", () => {
await waitFor(() => expect(api.validateRxresume).toHaveBeenCalled()); await waitFor(() => expect(api.validateRxresume).toHaveBeenCalled());
expect(api.validateLlm).not.toHaveBeenCalled(); expect(api.validateLlm).not.toHaveBeenCalled();
expect(screen.getByText("Welcome to Job Ops")).toBeInTheDocument(); await waitFor(() => {
expect(screen.getByText("Welcome to Job Ops")).toBeInTheDocument();
});
expect(screen.queryByText("LLM API key")).not.toBeInTheDocument(); expect(screen.queryByText("LLM API key")).not.toBeInTheDocument();
}); });
}); });

View File

@ -3,6 +3,7 @@ import type { Job } from "@shared/types.js";
import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import * as api from "../api"; import * as api from "../api";
import { _resetTracerReadinessCache } from "../hooks/useTracerReadiness";
import { TailoringEditor } from "./TailoringEditor"; import { TailoringEditor } from "./TailoringEditor";
vi.mock("../api", () => ({ vi.mock("../api", () => ({
@ -10,6 +11,7 @@ vi.mock("../api", () => ({
updateJob: vi.fn().mockResolvedValue({}), updateJob: vi.fn().mockResolvedValue({}),
summarizeJob: vi.fn(), summarizeJob: vi.fn(),
generateJobPdf: vi.fn(), generateJobPdf: vi.fn(),
getTracerReadiness: vi.fn(),
})); }));
vi.mock("sonner", () => ({ vi.mock("sonner", () => ({
@ -42,6 +44,16 @@ const ensureAccordionOpen = (name: string) => {
describe("TailoringEditor", () => { describe("TailoringEditor", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); 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("does not rehydrate local edits from same-job prop updates", async () => { it("does not rehydrate local edits from same-job prop updates", async () => {
@ -239,4 +251,30 @@ describe("TailoringEditor", () => {
expect(screen.getByDisplayValue("Backend")).toBeInTheDocument(); expect(screen.getByDisplayValue("Backend")).toBeInTheDocument();
expect(screen.getByDisplayValue("Node.js, Kafka")).toBeInTheDocument(); expect(screen.getByDisplayValue("Node.js, Kafka")).toBeInTheDocument();
}); });
it("persists tracer-links toggle in tailoring save payload", async () => {
render(
<TailoringEditor
job={createJob({ tracerLinksEnabled: false })}
onUpdate={vi.fn()}
/>,
);
await waitFor(() =>
expect(api.getResumeProjectsCatalog).toHaveBeenCalled(),
);
await waitFor(() => expect(api.getTracerReadiness).toHaveBeenCalled());
ensureAccordionOpen("Tracer Links");
fireEvent.click(screen.getByLabelText("Enable tracer links for this job"));
fireEvent.click(screen.getByRole("button", { name: "Save Selection" }));
await waitFor(() =>
expect(api.updateJob).toHaveBeenCalledWith(
"job-1",
expect.objectContaining({
tracerLinksEnabled: true,
}),
),
);
});
}); });

View File

@ -3,12 +3,14 @@ import type { Job } from "@shared/types.js";
import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import * as api from "../../api"; import * as api from "../../api";
import { _resetTracerReadinessCache } from "../../hooks/useTracerReadiness";
import { TailorMode } from "./TailorMode"; import { TailorMode } from "./TailorMode";
vi.mock("../../api", () => ({ vi.mock("../../api", () => ({
getResumeProjectsCatalog: vi.fn().mockResolvedValue([]), getResumeProjectsCatalog: vi.fn().mockResolvedValue([]),
updateJob: vi.fn(), updateJob: vi.fn(),
summarizeJob: vi.fn(), summarizeJob: vi.fn(),
getTracerReadiness: vi.fn(),
})); }));
vi.mock("sonner", () => ({ vi.mock("sonner", () => ({
@ -41,6 +43,16 @@ const ensureAccordionOpen = (name: string) => {
describe("TailorMode", () => { describe("TailorMode", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); 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("does not rehydrate local edits from same-job prop updates", async () => { it("does not rehydrate local edits from same-job prop updates", async () => {

View File

@ -3,6 +3,7 @@ import {
Home, Home,
Inbox, Inbox,
LayoutDashboard, LayoutDashboard,
Link2,
Settings, Settings,
Shield, Shield,
} from "lucide-react"; } from "lucide-react";
@ -34,6 +35,12 @@ export const NAV_LINKS: NavLink[] = [
activePaths: ["/applications/in-progress"], activePaths: ["/applications/in-progress"],
}, },
{ to: "/tracking-inbox", label: "Tracking Inbox", icon: Inbox }, { to: "/tracking-inbox", label: "Tracking Inbox", icon: Inbox },
{
to: "/tracer-links",
label: "Tracer Links",
icon: Link2,
activePaths: ["/tracer-links"],
},
{ to: "/visa-sponsors", label: "Visa Sponsors", icon: Shield }, { to: "/visa-sponsors", label: "Visa Sponsors", icon: Shield },
{ to: "/settings", label: "Settings", icon: Settings }, { to: "/settings", label: "Settings", icon: Settings },
]; ];

View File

@ -8,6 +8,7 @@ import {
AccordionTrigger, AccordionTrigger,
} from "@/components/ui/accordion"; } from "@/components/ui/accordion";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { ProjectSelector } from "../discovered-panel/ProjectSelector"; import { ProjectSelector } from "../discovered-panel/ProjectSelector";
import type { EditableSkillGroup } from "../tailoring-utils"; import type { EditableSkillGroup } from "../tailoring-utils";
@ -18,6 +19,10 @@ interface TailoringSectionsProps {
jobDescription: string; jobDescription: string;
skillsDraft: EditableSkillGroup[]; skillsDraft: EditableSkillGroup[];
selectedIds: Set<string>; selectedIds: Set<string>;
tracerLinksEnabled: boolean;
tracerEnableBlocked: boolean;
tracerEnableBlockedReason: string | null;
tracerReadinessChecking?: boolean;
openSkillGroupId: string; openSkillGroupId: string;
disableInputs: boolean; disableInputs: boolean;
onSummaryChange: (value: string) => void; onSummaryChange: (value: string) => void;
@ -32,6 +37,7 @@ interface TailoringSectionsProps {
) => void; ) => void;
onRemoveSkillGroup: (id: string) => void; onRemoveSkillGroup: (id: string) => void;
onToggleProject: (id: string) => void; onToggleProject: (id: string) => void;
onTracerLinksEnabledChange: (value: boolean) => void;
} }
const sectionClass = "rounded-lg border border-border/60 bg-muted/20 px-0"; const sectionClass = "rounded-lg border border-border/60 bg-muted/20 px-0";
@ -47,6 +53,10 @@ export const TailoringSections: React.FC<TailoringSectionsProps> = ({
jobDescription, jobDescription,
skillsDraft, skillsDraft,
selectedIds, selectedIds,
tracerLinksEnabled,
tracerEnableBlocked,
tracerEnableBlockedReason,
tracerReadinessChecking = false,
openSkillGroupId, openSkillGroupId,
disableInputs, disableInputs,
onSummaryChange, onSummaryChange,
@ -57,7 +67,11 @@ export const TailoringSections: React.FC<TailoringSectionsProps> = ({
onUpdateSkillGroup, onUpdateSkillGroup,
onRemoveSkillGroup, onRemoveSkillGroup,
onToggleProject, onToggleProject,
onTracerLinksEnabledChange,
}) => { }) => {
const tracerToggleDisabled =
disableInputs || (!tracerLinksEnabled && tracerEnableBlocked);
return ( return (
<Accordion type="multiple" className="space-y-3"> <Accordion type="multiple" className="space-y-3">
<AccordionItem value="job-description" className={sectionClass}> <AccordionItem value="job-description" className={sectionClass}>
@ -239,6 +253,42 @@ export const TailoringSections: React.FC<TailoringSectionsProps> = ({
/> />
</AccordionContent> </AccordionContent>
</AccordionItem> </AccordionItem>
<AccordionItem value="tracer-links" className={sectionClass}>
<AccordionTrigger className={triggerClass}>
Tracer Links
</AccordionTrigger>
<AccordionContent className="px-3 pb-3 pt-1">
<div className="rounded-md border border-border/60 bg-background/60 p-3">
<label
htmlFor="tailor-tracer-links-enabled"
className="flex cursor-pointer items-center gap-3"
>
<Checkbox
id="tailor-tracer-links-enabled"
checked={tracerLinksEnabled}
onCheckedChange={(checked) =>
onTracerLinksEnabledChange(Boolean(checked))
}
disabled={tracerToggleDisabled}
/>
<span className="text-sm font-medium text-foreground">
Enable tracer links for this job
</span>
</label>
<p className="mt-2 text-xs text-muted-foreground">
{tracerReadinessChecking
? "Checking tracer-link readiness..."
: "When enabled, outgoing resume links are rewritten to JobOps tracer links on the next PDF generation. Existing PDFs are unchanged."}
</p>
{tracerEnableBlockedReason && !tracerLinksEnabled ? (
<p className="mt-2 text-xs text-destructive">
Tracer links are unavailable: {tracerEnableBlockedReason}
</p>
) : null}
</div>
</AccordionContent>
</AccordionItem>
</Accordion> </Accordion>
); );
}; };

View File

@ -6,6 +6,7 @@ import { toast } from "sonner";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import * as api from "../../api"; import * as api from "../../api";
import { useTracerReadiness } from "../../hooks/useTracerReadiness";
import { TailoringSections } from "./TailoringSections"; import { TailoringSections } from "./TailoringSections";
import { useTailoringDraft } from "./useTailoringDraft"; import { useTailoringDraft } from "./useTailoringDraft";
@ -49,6 +50,8 @@ export const TailoringWorkspace: React.FC<TailoringWorkspaceProps> = (
setJobDescription, setJobDescription,
selectedIds, selectedIds,
selectedIdsCsv, selectedIdsCsv,
tracerLinksEnabled,
setTracerLinksEnabled,
skillsDraft, skillsDraft,
openSkillGroupId, openSkillGroupId,
setOpenSkillGroupId, setOpenSkillGroupId,
@ -68,6 +71,16 @@ export const TailoringWorkspace: React.FC<TailoringWorkspaceProps> = (
const [isSummarizing, setIsSummarizing] = useState(false); const [isSummarizing, setIsSummarizing] = useState(false);
const [isGeneratingPdf, setIsGeneratingPdf] = useState(false); const [isGeneratingPdf, setIsGeneratingPdf] = useState(false);
const [isGenerating, setIsGenerating] = useState(false); const [isGenerating, setIsGenerating] = useState(false);
const { readiness: tracerReadiness, isChecking: isTracerReadinessChecking } =
useTracerReadiness();
const tracerEnableBlocked =
!tracerLinksEnabled && !tracerReadiness?.canEnable;
const tracerEnableBlockedReason =
tracerReadiness?.canEnable === false
? (tracerReadiness.reason ??
"Verify tracer links in Settings before enabling this job.")
: null;
const savePayload = useMemo( const savePayload = useMemo(
() => ({ () => ({
@ -76,8 +89,16 @@ export const TailoringWorkspace: React.FC<TailoringWorkspaceProps> = (
tailoredSkills: skillsJson, tailoredSkills: skillsJson,
jobDescription, jobDescription,
selectedProjectIds: selectedIdsCsv, selectedProjectIds: selectedIdsCsv,
tracerLinksEnabled,
}), }),
[summary, headline, skillsJson, jobDescription, selectedIdsCsv], [
summary,
headline,
skillsJson,
jobDescription,
selectedIdsCsv,
tracerLinksEnabled,
],
); );
const persistCurrent = useCallback(async () => { const persistCurrent = useCallback(async () => {
@ -176,8 +197,16 @@ export const TailoringWorkspace: React.FC<TailoringWorkspaceProps> = (
await api.generateJobPdf(props.job.id); await api.generateJobPdf(props.job.id);
toast.success("Resume PDF generated"); toast.success("Resume PDF generated");
await editorProps.onUpdate(); await editorProps.onUpdate();
} catch { } catch (error) {
toast.error("PDF generation failed"); const message =
error instanceof Error ? error.message : "PDF generation failed";
if (/tracer/i.test(message)) {
toast.error("Tracer links are unavailable right now", {
description: message,
});
} else {
toast.error(message);
}
} finally { } finally {
setIsGeneratingPdf(false); setIsGeneratingPdf(false);
} }
@ -256,6 +285,10 @@ export const TailoringWorkspace: React.FC<TailoringWorkspaceProps> = (
jobDescription={jobDescription} jobDescription={jobDescription}
skillsDraft={skillsDraft} skillsDraft={skillsDraft}
selectedIds={selectedIds} selectedIds={selectedIds}
tracerLinksEnabled={tracerLinksEnabled}
tracerEnableBlocked={tracerEnableBlocked}
tracerEnableBlockedReason={tracerEnableBlockedReason}
tracerReadinessChecking={isTracerReadinessChecking}
openSkillGroupId={openSkillGroupId} openSkillGroupId={openSkillGroupId}
disableInputs={disableInputs} disableInputs={disableInputs}
onSummaryChange={setSummary} onSummaryChange={setSummary}
@ -266,6 +299,7 @@ export const TailoringWorkspace: React.FC<TailoringWorkspaceProps> = (
onUpdateSkillGroup={handleUpdateSkillGroup} onUpdateSkillGroup={handleUpdateSkillGroup}
onRemoveSkillGroup={handleRemoveSkillGroup} onRemoveSkillGroup={handleRemoveSkillGroup}
onToggleProject={handleToggleProject} onToggleProject={handleToggleProject}
onTracerLinksEnabledChange={setTracerLinksEnabled}
/> />
<div className="flex justify-end border-t pt-4"> <div className="flex justify-end border-t pt-4">
@ -342,6 +376,10 @@ export const TailoringWorkspace: React.FC<TailoringWorkspaceProps> = (
jobDescription={jobDescription} jobDescription={jobDescription}
skillsDraft={skillsDraft} skillsDraft={skillsDraft}
selectedIds={selectedIds} selectedIds={selectedIds}
tracerLinksEnabled={tracerLinksEnabled}
tracerEnableBlocked={tracerEnableBlocked}
tracerEnableBlockedReason={tracerEnableBlockedReason}
tracerReadinessChecking={isTracerReadinessChecking}
openSkillGroupId={openSkillGroupId} openSkillGroupId={openSkillGroupId}
disableInputs={disableInputs} disableInputs={disableInputs}
onSummaryChange={setSummary} onSummaryChange={setSummary}
@ -352,6 +390,7 @@ export const TailoringWorkspace: React.FC<TailoringWorkspaceProps> = (
onUpdateSkillGroup={handleUpdateSkillGroup} onUpdateSkillGroup={handleUpdateSkillGroup}
onRemoveSkillGroup={handleRemoveSkillGroup} onRemoveSkillGroup={handleRemoveSkillGroup}
onToggleProject={handleToggleProject} onToggleProject={handleToggleProject}
onTracerLinksEnabledChange={setTracerLinksEnabled}
/> />
</div> </div>

View File

@ -32,6 +32,7 @@ const parseIncomingDraft = (incomingJob: Job) => {
const skillsJson = serializeTailoredSkills( const skillsJson = serializeTailoredSkills(
fromEditableSkillGroups(skillsDraft), fromEditableSkillGroups(skillsDraft),
); );
const tracerLinksEnabled = Boolean(incomingJob.tracerLinksEnabled);
return { return {
summary, summary,
@ -40,6 +41,7 @@ const parseIncomingDraft = (incomingJob: Job) => {
selectedIds, selectedIds,
skillsDraft, skillsDraft,
skillsJson, skillsJson,
tracerLinksEnabled,
}; };
}; };
@ -65,6 +67,9 @@ export function useTailoringDraft({
toEditableSkillGroups(parseTailoredSkills(job.tailoredSkills)), toEditableSkillGroups(parseTailoredSkills(job.tailoredSkills)),
); );
const [openSkillGroupId, setOpenSkillGroupId] = useState<string>(""); const [openSkillGroupId, setOpenSkillGroupId] = useState<string>("");
const [tracerLinksEnabled, setTracerLinksEnabled] = useState(
Boolean(job.tracerLinksEnabled),
);
const [savedSummary, setSavedSummary] = useState(job.tailoredSummary || ""); const [savedSummary, setSavedSummary] = useState(job.tailoredSummary || "");
const [savedHeadline, setSavedHeadline] = useState( const [savedHeadline, setSavedHeadline] = useState(
@ -79,6 +84,9 @@ export function useTailoringDraft({
const [savedSkillsJson, setSavedSkillsJson] = useState(() => const [savedSkillsJson, setSavedSkillsJson] = useState(() =>
serializeTailoredSkills(parseTailoredSkills(job.tailoredSkills)), serializeTailoredSkills(parseTailoredSkills(job.tailoredSkills)),
); );
const [savedTracerLinksEnabled, setSavedTracerLinksEnabled] = useState(
Boolean(job.tracerLinksEnabled),
);
const lastJobIdRef = useRef(job.id); const lastJobIdRef = useRef(job.id);
const jobRef = useRef(job); const jobRef = useRef(job);
@ -98,6 +106,7 @@ export function useTailoringDraft({
if (headline !== savedHeadline) return true; if (headline !== savedHeadline) return true;
if (jobDescription !== savedDescription) return true; if (jobDescription !== savedDescription) return true;
if (skillsJson !== savedSkillsJson) return true; if (skillsJson !== savedSkillsJson) return true;
if (tracerLinksEnabled !== savedTracerLinksEnabled) return true;
return hasSelectionDiff(selectedIds, savedSelectedIds); return hasSelectionDiff(selectedIds, savedSelectedIds);
}, [ }, [
summary, summary,
@ -108,6 +117,8 @@ export function useTailoringDraft({
savedDescription, savedDescription,
skillsJson, skillsJson,
savedSkillsJson, savedSkillsJson,
tracerLinksEnabled,
savedTracerLinksEnabled,
selectedIds, selectedIds,
savedSelectedIds, savedSelectedIds,
]); ]);
@ -124,6 +135,8 @@ export function useTailoringDraft({
setSavedDescription(next.description); setSavedDescription(next.description);
setSavedSelectedIds(next.selectedIds); setSavedSelectedIds(next.selectedIds);
setSavedSkillsJson(next.skillsJson); setSavedSkillsJson(next.skillsJson);
setTracerLinksEnabled(next.tracerLinksEnabled);
setSavedTracerLinksEnabled(next.tracerLinksEnabled);
}, []); }, []);
useEffect(() => { useEffect(() => {
@ -210,6 +223,8 @@ export function useTailoringDraft({
openSkillGroupId, openSkillGroupId,
setOpenSkillGroupId, setOpenSkillGroupId,
skillsJson, skillsJson,
tracerLinksEnabled,
setTracerLinksEnabled,
isDirty, isDirty,
applyIncomingDraft, applyIncomingDraft,
handleToggleProject, handleToggleProject,

View File

@ -0,0 +1,101 @@
import type { TracerReadinessResponse } from "@shared/types";
import { useEffect, useState } from "react";
import * as api from "../api";
let readinessCache: TracerReadinessResponse | null = null;
let readinessError: Error | null = null;
let isFetching = false;
const subscribers: Set<
(
readiness: TracerReadinessResponse | null,
error: Error | null,
loading: boolean,
) => void
> = new Set();
function notifySubscribers(
readiness: TracerReadinessResponse | null,
error: Error | null,
loading: boolean,
) {
for (const subscriber of subscribers) {
subscriber(readiness, error, loading);
}
}
async function runReadinessFetch(
force: boolean,
): Promise<TracerReadinessResponse> {
isFetching = true;
readinessError = null;
notifySubscribers(readinessCache, null, true);
try {
const data = await api.getTracerReadiness({ force });
readinessCache = data;
readinessError = null;
notifySubscribers(data, null, false);
return data;
} catch (error) {
readinessError = error instanceof Error ? error : new Error(String(error));
notifySubscribers(readinessCache, readinessError, false);
throw readinessError;
} finally {
isFetching = false;
}
}
export function useTracerReadiness() {
const [readiness, setReadiness] = useState<TracerReadinessResponse | null>(
readinessCache,
);
const [error, setError] = useState<Error | null>(readinessError);
const [loading, setLoading] = useState<boolean>(
!readinessCache && isFetching,
);
useEffect(() => {
if (readinessCache) setReadiness(readinessCache);
if (readinessError) setError(readinessError);
const handleUpdate = (
nextReadiness: TracerReadinessResponse | null,
nextError: Error | null,
nextLoading: boolean,
) => {
setReadiness(nextReadiness);
setError(nextError);
setLoading(nextLoading);
};
subscribers.add(handleUpdate);
if (!readinessCache && !isFetching) {
void runReadinessFetch(false);
}
return () => {
subscribers.delete(handleUpdate);
};
}, []);
const refreshReadiness = async (force = true) => {
return await runReadinessFetch(force);
};
return {
readiness,
error,
isLoading: loading && !readiness,
isChecking: loading,
refreshReadiness,
};
}
/** @internal For testing only */
export function _resetTracerReadinessCache() {
readinessCache = null;
readinessError = null;
isFetching = false;
subscribers.clear();
}

View File

@ -4,6 +4,7 @@ import { MemoryRouter } from "react-router-dom";
import { toast } from "sonner"; import { toast } from "sonner";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import * as api from "../api"; import * as api from "../api";
import { _resetTracerReadinessCache } from "../hooks/useTracerReadiness";
import { SettingsPage } from "./SettingsPage"; import { SettingsPage } from "./SettingsPage";
vi.mock("../api", () => ({ vi.mock("../api", () => ({
@ -11,6 +12,10 @@ vi.mock("../api", () => ({
updateSettings: vi.fn(), updateSettings: vi.fn(),
clearDatabase: vi.fn(), clearDatabase: vi.fn(),
deleteJobsByStatus: vi.fn(), deleteJobsByStatus: vi.fn(),
getTracerReadiness: vi.fn(),
getBackups: vi.fn().mockResolvedValue({ backups: [], nextScheduled: null }),
createManualBackup: vi.fn(),
deleteBackup: vi.fn(),
})); }));
vi.mock("sonner", () => ({ vi.mock("sonner", () => ({
@ -78,6 +83,16 @@ const renderPage = () => {
describe("SettingsPage", () => { describe("SettingsPage", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); 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 trimmed model overrides", async () => { it("saves trimmed model overrides", async () => {

View File

@ -1,5 +1,6 @@
import * as api from "@client/api"; import * as api from "@client/api";
import { PageHeader } from "@client/components/layout"; import { PageHeader } from "@client/components/layout";
import { useTracerReadiness } from "@client/hooks/useTracerReadiness";
import { BackupSettingsSection } from "@client/pages/settings/components/BackupSettingsSection"; import { BackupSettingsSection } from "@client/pages/settings/components/BackupSettingsSection";
import { ChatSettingsSection } from "@client/pages/settings/components/ChatSettingsSection"; import { ChatSettingsSection } from "@client/pages/settings/components/ChatSettingsSection";
import { DangerZoneSection } from "@client/pages/settings/components/DangerZoneSection"; import { DangerZoneSection } from "@client/pages/settings/components/DangerZoneSection";
@ -8,6 +9,7 @@ import { EnvironmentSettingsSection } from "@client/pages/settings/components/En
import { ModelSettingsSection } from "@client/pages/settings/components/ModelSettingsSection"; import { ModelSettingsSection } from "@client/pages/settings/components/ModelSettingsSection";
import { ReactiveResumeSection } from "@client/pages/settings/components/ReactiveResumeSection"; import { ReactiveResumeSection } from "@client/pages/settings/components/ReactiveResumeSection";
import { ScoringSettingsSection } from "@client/pages/settings/components/ScoringSettingsSection"; import { ScoringSettingsSection } from "@client/pages/settings/components/ScoringSettingsSection";
import { TracerLinksSettingsSection } from "@client/pages/settings/components/TracerLinksSettingsSection";
import { WebhooksSection } from "@client/pages/settings/components/WebhooksSection"; import { WebhooksSection } from "@client/pages/settings/components/WebhooksSection";
import { import {
type LlmProviderId, type LlmProviderId,
@ -312,6 +314,12 @@ export const SettingsPage: React.FC = () => {
const [isLoadingBackups, setIsLoadingBackups] = useState(false); const [isLoadingBackups, setIsLoadingBackups] = useState(false);
const [isCreatingBackup, setIsCreatingBackup] = useState(false); const [isCreatingBackup, setIsCreatingBackup] = useState(false);
const [isDeletingBackup, setIsDeletingBackup] = useState(false); const [isDeletingBackup, setIsDeletingBackup] = useState(false);
const {
readiness: tracerReadiness,
isLoading: isTracerReadinessLoading,
isChecking: isTracerReadinessChecking,
refreshReadiness,
} = useTracerReadiness();
const methods = useForm<UpdateSettingsInput>({ const methods = useForm<UpdateSettingsInput>({
resolver: zodResolver( resolver: zodResolver(
@ -486,6 +494,26 @@ export const SettingsPage: React.FC = () => {
} }
}; };
const handleVerifyTracerReadiness = useCallback(async () => {
try {
const readiness = await refreshReadiness(true);
if (readiness.canEnable) {
toast.success("Tracer links are ready");
} else {
toast.error(
readiness.reason ??
"Tracer links are unavailable. Verify your public URL.",
);
}
} catch (error) {
const message =
error instanceof Error
? error.message
: "Failed to verify tracer-link readiness";
toast.error(message);
}
}, [refreshReadiness]);
// Load backups when settings are loaded // Load backups when settings are loaded
useEffect(() => { useEffect(() => {
if (settings) { if (settings) {
@ -779,6 +807,12 @@ export const SettingsPage: React.FC = () => {
isLoading={isLoading} isLoading={isLoading}
isSaving={isSaving} isSaving={isSaving}
/> />
<TracerLinksSettingsSection
readiness={tracerReadiness}
isLoading={isLoading || isTracerReadinessLoading}
isChecking={isTracerReadinessChecking}
onVerifyNow={handleVerifyTracerReadiness}
/>
<DisplaySettingsSection <DisplaySettingsSection
values={display} values={display}
isLoading={isLoading} isLoading={isLoading}

View File

@ -0,0 +1,644 @@
import * as api from "@client/api";
import { PageHeader, PageMain, SectionCard } from "@client/components/layout";
import type {
JobTracerLinkAnalyticsItem,
JobTracerLinksResponse,
TracerAnalyticsResponse,
TracerAnalyticsTopJob,
} from "@shared/types.js";
import { BarChart3, Copy, ExternalLink, Loader2 } from "lucide-react";
import type React from "react";
import { useEffect, useMemo, useState } from "react";
import { Bar, BarChart, CartesianGrid, XAxis, YAxis } from "recharts";
import { toast } from "sonner";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { Button } from "@/components/ui/button";
import {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
} from "@/components/ui/chart";
import { Checkbox } from "@/components/ui/checkbox";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { copyTextToClipboard } from "@/lib/utils";
const chartConfig = {
clicks: {
label: "Clicks",
color: "var(--chart-1)",
},
};
function formatUnixTimestamp(value: number | null): string {
if (value === null || !Number.isFinite(value)) return "-";
return new Date(value * 1000).toLocaleString();
}
function formatRecentActivity(value: number | null): string {
if (value === null || !Number.isFinite(value)) return "-";
const date = new Date(value * 1000);
const now = new Date();
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const yesterday = new Date(today);
yesterday.setDate(yesterday.getDate() - 1);
const timeText = date.toLocaleTimeString([], {
hour: "numeric",
minute: "2-digit",
});
if (date >= today) return `Today ${timeText}`;
if (date >= yesterday) return `Yesterday ${timeText}`;
return date.toLocaleDateString([], {
month: "short",
day: "numeric",
});
}
function toUnixStartOfDay(value: string): number | undefined {
if (!value) return undefined;
const date = new Date(`${value}T00:00:00`);
if (Number.isNaN(date.getTime())) return undefined;
return Math.floor(date.getTime() / 1000);
}
function toUnixEndOfDay(value: string): number | undefined {
if (!value) return undefined;
const date = new Date(`${value}T23:59:59`);
if (Number.isNaN(date.getTime())) return undefined;
return Math.floor(date.getTime() / 1000);
}
function formatDayLabel(day: string): string {
const date = new Date(`${day}T00:00:00`);
if (Number.isNaN(date.getTime())) return day;
return date.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
});
}
function formatRelativeTime(value: number | null): string {
if (value === null || !Number.isFinite(value)) return "No activity yet";
const diffSeconds = Math.max(0, Math.floor(Date.now() / 1000) - value);
if (diffSeconds < 60) return "just now";
const diffMinutes = Math.floor(diffSeconds / 60);
if (diffMinutes < 60) {
return `${diffMinutes} minute${diffMinutes === 1 ? "" : "s"} ago`;
}
const diffHours = Math.floor(diffMinutes / 60);
if (diffHours < 24)
return `${diffHours} hour${diffHours === 1 ? "" : "s"} ago`;
const diffDays = Math.floor(diffHours / 24);
return `${diffDays} day${diffDays === 1 ? "" : "s"} ago`;
}
export const TracerLinksPage: React.FC = () => {
const [analytics, setAnalytics] = useState<TracerAnalyticsResponse | null>(
null,
);
const [jobDrilldown, setJobDrilldown] =
useState<JobTracerLinksResponse | null>(null);
const [fromDate, setFromDate] = useState("");
const [toDate, setToDate] = useState("");
const [includeBots, setIncludeBots] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [isDrilldownLoading, setIsDrilldownLoading] = useState(false);
const [isDrilldownOpen, setIsDrilldownOpen] = useState(false);
const [drilldownMode, setDrilldownMode] = useState<"human" | "all">("human");
const [error, setError] = useState<string | null>(null);
const query = useMemo(
() => ({
from: toUnixStartOfDay(fromDate),
to: toUnixEndOfDay(toDate),
includeBots,
limit: 20,
}),
[fromDate, toDate, includeBots],
);
const loadJobDrilldown = async (targetJobId: string) => {
if (!targetJobId) {
setError("Enter a Job ID to load link drilldown.");
setJobDrilldown(null);
return;
}
try {
setIsDrilldownLoading(true);
setError(null);
const response = await api.getJobTracerLinks(targetJobId, {
from: query.from,
to: query.to,
includeBots,
});
setJobDrilldown(response);
} catch (fetchError) {
const message =
fetchError instanceof Error
? fetchError.message
: "Failed to load job tracer links.";
setError(message);
setJobDrilldown(null);
} finally {
setIsDrilldownLoading(false);
}
};
useEffect(() => {
let isMounted = true;
setIsLoading(true);
setError(null);
api
.getTracerAnalytics(query)
.then((response) => {
if (!isMounted) return;
setAnalytics(response);
})
.catch((fetchError) => {
if (!isMounted) return;
const message =
fetchError instanceof Error
? fetchError.message
: "Failed to load tracer analytics.";
setError(message);
})
.finally(() => {
if (!isMounted) return;
setIsLoading(false);
});
return () => {
isMounted = false;
};
}, [query]);
const chartData = analytics?.timeSeries ?? [];
const totalViews = analytics?.totals.clicks ?? 0;
const humanClicks = analytics?.totals.humanClicks ?? 0;
const uniqueJobsReached = useMemo(() => {
if (!analytics) return 0;
const jobIds = new Set(analytics.topJobs.map((job) => job.jobId));
if (jobIds.size > 0) return jobIds.size;
for (const row of analytics.topLinks) {
jobIds.add(row.jobId);
}
return jobIds.size;
}, [analytics]);
const visibleDays = useMemo(() => {
if (query.from && query.to && query.to >= query.from) {
const secondsPerDay = 24 * 60 * 60;
return Math.floor((query.to - query.from) / secondsPerDay) + 1;
}
return chartData.length > 0 ? chartData.length : 30;
}, [chartData.length, query.from, query.to]);
const selectedJobId = jobDrilldown?.job.id ?? null;
const drilldownGroupedLinks = useMemo(() => {
if (!jobDrilldown) {
return { active: [], inactive: [] } as const;
}
const hasActivity = (row: JobTracerLinkAnalyticsItem) =>
drilldownMode === "human" ? row.humanClicks > 0 : row.clicks > 0;
const uniqueOpens = (row: JobTracerLinkAnalyticsItem) =>
drilldownMode === "human" ? row.humanClicks : row.uniqueOpens;
const active = jobDrilldown.links.filter(hasActivity).sort((a, b) => {
const lastClickDelta = (b.lastClickedAt ?? 0) - (a.lastClickedAt ?? 0);
if (lastClickDelta !== 0) return lastClickDelta;
const uniqueDelta = uniqueOpens(b) - uniqueOpens(a);
if (uniqueDelta !== 0) return uniqueDelta;
return b.humanClicks - a.humanClicks;
});
const inactive = jobDrilldown.links
.filter((row) => !hasActivity(row))
.sort((a, b) => a.destinationUrl.localeCompare(b.destinationUrl));
return { active, inactive } as const;
}, [drilldownMode, jobDrilldown]);
const drilldownSummary = useMemo(() => {
if (!jobDrilldown) return null;
const rows = jobDrilldown.links;
const humanClicks = rows.reduce((total, row) => total + row.humanClicks, 0);
const totalClicks = rows.reduce(
(total, row) =>
total + (drilldownMode === "human" ? row.humanClicks : row.clicks),
0,
);
const lastActivityAt = rows.reduce<number | null>((latest, row) => {
const count = drilldownMode === "human" ? row.humanClicks : row.clicks;
if (count <= 0 || row.lastClickedAt === null) return latest;
if (latest === null || row.lastClickedAt > latest)
return row.lastClickedAt;
return latest;
}, null);
return { humanClicks, totalClicks, lastActivityAt };
}, [drilldownMode, jobDrilldown]);
const handleCopyDestination = async (destinationUrl: string) => {
try {
await copyTextToClipboard(destinationUrl);
toast.success("Link copied");
} catch {
toast.error("Could not copy link");
}
};
const getRowClicks = (row: JobTracerLinkAnalyticsItem) =>
drilldownMode === "human" ? row.humanClicks : row.clicks;
const handleSelectTopJob = (job: TracerAnalyticsTopJob) => {
setIsDrilldownOpen(true);
void loadJobDrilldown(job.jobId);
};
return (
<>
<PageHeader
icon={BarChart3}
title="Tracer Links"
subtitle="Outbound resume link analytics"
/>
<PageMain>
<SectionCard className="p-0">
<Accordion type="single" collapsible className="w-full">
<AccordionItem value="filters" className="border-none">
<AccordionTrigger className="px-4 py-3 hover:no-underline">
<div className="text-sm font-semibold">Filters</div>
</AccordionTrigger>
<AccordionContent className="px-4 pb-4">
<div className="grid gap-3 md:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto]">
<div className="space-y-1">
<Label htmlFor="tracer-from-date">From date</Label>
<Input
id="tracer-from-date"
type="date"
value={fromDate}
onChange={(event) => setFromDate(event.target.value)}
/>
</div>
<div className="space-y-1">
<Label htmlFor="tracer-to-date">To date</Label>
<Input
id="tracer-to-date"
type="date"
value={toDate}
onChange={(event) => setToDate(event.target.value)}
/>
</div>
<label
htmlFor="tracer-include-bots"
className="flex cursor-pointer items-end gap-2 pb-2"
>
<Checkbox
id="tracer-include-bots"
checked={includeBots}
onCheckedChange={(checked) =>
setIncludeBots(Boolean(checked))
}
/>
<span className="text-sm">Include likely bots</span>
</label>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
</SectionCard>
{error && (
<SectionCard>
<p className="text-sm text-destructive">{error}</p>
</SectionCard>
)}
<div className="grid gap-3 md:grid-cols-3">
<SectionCard className="space-y-1">
<p className="text-xs text-muted-foreground">Total Views</p>
<p className="text-3xl font-semibold tabular-nums">
{totalViews.toLocaleString()}
</p>
</SectionCard>
<SectionCard className="space-y-1">
<p className="text-xs text-muted-foreground">Unique Jobs Reached</p>
<p className="text-3xl font-semibold tabular-nums">
{uniqueJobsReached.toLocaleString()}
</p>
</SectionCard>
<SectionCard className="space-y-1">
<p className="text-xs text-muted-foreground">Human Clicks</p>
<p className="text-3xl font-semibold tabular-nums">
{humanClicks.toLocaleString()}
</p>
</SectionCard>
</div>
<SectionCard className="space-y-4">
<div className="flex items-center justify-between gap-4">
<div>
<h2 className="text-sm font-semibold">
Resume Clicks Last {visibleDays} Days
</h2>
<p className="text-xs text-muted-foreground">
Daily click activity from tracer links.
</p>
</div>
</div>
{isLoading ? (
<div className="flex h-[240px] items-center justify-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
Loading analytics...
</div>
) : (
<ChartContainer config={chartConfig} className="h-[240px] w-full">
<BarChart
data={chartData}
margin={{ top: 8, right: 8, left: -12, bottom: 0 }}
>
<CartesianGrid vertical={false} />
<XAxis
dataKey="day"
axisLine={false}
tickLine={false}
tickMargin={8}
minTickGap={24}
tickFormatter={(value) => formatDayLabel(String(value))}
/>
<YAxis axisLine={false} tickLine={false} width={30} />
<ChartTooltip
cursor={{ fill: "var(--color-clicks)", opacity: 0.18 }}
content={
<ChartTooltipContent
nameKey="clicks"
labelFormatter={(value) => formatDayLabel(String(value))}
/>
}
/>
<Bar
dataKey="clicks"
fill="var(--color-clicks)"
radius={[4, 4, 0, 0]}
/>
</BarChart>
</ChartContainer>
)}
</SectionCard>
<SectionCard>
<div className="mb-3">
<h3 className="text-sm font-semibold">Application Activity</h3>
</div>
<Table>
<TableHeader>
<TableRow>
<TableHead>Job</TableHead>
<TableHead className="w-[90px]">Clicks</TableHead>
<TableHead className="w-[140px]">Last active</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{(analytics?.topJobs ?? []).map((row) => (
<TableRow
key={row.jobId}
className="cursor-pointer"
data-state={
selectedJobId === row.jobId ? "selected" : undefined
}
onClick={() => handleSelectTopJob(row)}
>
<TableCell>
<div className="font-medium">{row.title}</div>
<div className="text-xs text-muted-foreground">
{row.employer}
</div>
</TableCell>
<TableCell>{row.clicks}</TableCell>
<TableCell>
{formatRecentActivity(row.lastClickedAt)}
</TableCell>
</TableRow>
))}
{(analytics?.topJobs.length ?? 0) === 0 && !isLoading && (
<TableRow>
<TableCell
colSpan={3}
className="text-sm text-muted-foreground"
>
No tracer-link activity yet.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</SectionCard>
<Dialog open={isDrilldownOpen} onOpenChange={setIsDrilldownOpen}>
<DialogContent className="max-h-[80vh] max-w-3xl overflow-hidden">
<DialogHeader>
<DialogTitle>
Job Links{jobDrilldown ? `: ${jobDrilldown.job.title}` : ""}
</DialogTitle>
<DialogDescription>
Destination links and click activity for the selected job.
</DialogDescription>
</DialogHeader>
<div className="space-y-2 overflow-y-auto pr-1">
{isDrilldownLoading ? (
<div className="flex items-center gap-2 py-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
Loading links...
</div>
) : jobDrilldown ? (
<>
<div className="rounded-md border border-border/60 bg-muted/20 px-3 py-2">
<div className="grid gap-2 text-xs sm:grid-cols-3">
<p>
Human clicks:{" "}
<span className="font-semibold tabular-nums">
{drilldownSummary?.humanClicks ?? 0}
</span>
</p>
<p>
Total clicks:{" "}
<span className="font-semibold tabular-nums">
{drilldownSummary?.totalClicks ?? 0}
</span>
</p>
<p>
Last activity:{" "}
<span className="font-semibold">
{formatRelativeTime(
drilldownSummary?.lastActivityAt ?? null,
)}
</span>
</p>
</div>
<div className="mt-2 flex gap-2">
<Button
type="button"
size="sm"
variant={
drilldownMode === "human" ? "default" : "outline"
}
onClick={() => setDrilldownMode("human")}
>
Human only
</Button>
<Button
type="button"
size="sm"
variant={
drilldownMode === "all" ? "default" : "outline"
}
onClick={() => setDrilldownMode("all")}
>
Human + bots
</Button>
</div>
</div>
{drilldownGroupedLinks.active.map((row) => (
<div
key={row.tracerLinkId}
className="flex items-center justify-between gap-3 rounded-md border border-border/60 px-3 py-2"
>
<div className="min-w-0">
<p className="truncate text-sm">{row.destinationUrl}</p>
<p className="truncate text-xs text-muted-foreground">
Last click: {formatUnixTimestamp(row.lastClickedAt)}
</p>
</div>
<div className="flex items-center gap-2">
<p className="text-sm font-semibold tabular-nums">
{getRowClicks(row)} Clicks
</p>
<a
href={row.destinationUrl}
target="_blank"
rel="noreferrer"
className="inline-flex"
>
<Button
type="button"
size="icon"
variant="ghost"
className="h-7 w-7"
>
<ExternalLink className="h-4 w-4" />
</Button>
</a>
<Button
type="button"
size="icon"
variant="ghost"
className="h-7 w-7"
onClick={() =>
void handleCopyDestination(row.destinationUrl)
}
>
<Copy className="h-4 w-4" />
</Button>
</div>
</div>
))}
{drilldownGroupedLinks.inactive.length > 0 && (
<Accordion type="single" collapsible className="w-full">
<AccordionItem value="inactive-links">
<AccordionTrigger className="py-2 text-sm hover:no-underline">
No activity yet (
{drilldownGroupedLinks.inactive.length})
</AccordionTrigger>
<AccordionContent className="space-y-2 pt-1">
{drilldownGroupedLinks.inactive.map((row) => (
<div
key={row.tracerLinkId}
className="flex items-center justify-between gap-3 rounded-md border border-border/60 px-3 py-2"
>
<div className="min-w-0">
<p className="truncate text-sm">
{row.destinationUrl}
</p>
<p className="truncate text-xs text-muted-foreground">
Last click:{" "}
{formatUnixTimestamp(row.lastClickedAt)}
</p>
</div>
<div className="flex items-center gap-2">
<p className="text-sm font-semibold tabular-nums">
{getRowClicks(row)} Clicks
</p>
<a
href={row.destinationUrl}
target="_blank"
rel="noreferrer"
className="inline-flex"
>
<Button
type="button"
size="icon"
variant="ghost"
className="h-7 w-7"
>
<ExternalLink className="h-4 w-4" />
</Button>
</a>
<Button
type="button"
size="icon"
variant="ghost"
className="h-7 w-7"
onClick={() =>
void handleCopyDestination(
row.destinationUrl,
)
}
>
<Copy className="h-4 w-4" />
</Button>
</div>
</div>
))}
</AccordionContent>
</AccordionItem>
</Accordion>
)}
{jobDrilldown.links.length === 0 && (
<p className="text-sm text-muted-foreground">
No tracer links recorded for this job yet.
</p>
)}
</>
) : (
<p className="text-sm text-muted-foreground">
Select a job from Application Activity.
</p>
)}
</div>
</DialogContent>
</Dialog>
</PageMain>
</>
);
};

View File

@ -0,0 +1,148 @@
import type { TracerReadinessResponse } from "@shared/types";
import { AlertCircle, CheckCircle2, Loader2, RefreshCw } from "lucide-react";
import type React from "react";
import {
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
type TracerLinksSettingsSectionProps = {
readiness: TracerReadinessResponse | null;
isLoading: boolean;
isChecking: boolean;
onVerifyNow: () => void | Promise<void>;
};
const STALE_AFTER_MS = 15 * 60_000;
function formatLastChecked(value: number | null): string {
if (!value) return "Never";
const date = new Date(value);
return date.toLocaleString();
}
function deriveStatus(
readiness: TracerReadinessResponse | null,
isChecking: boolean,
): {
label: string;
className: string;
icon: React.ReactNode;
} {
if (isChecking) {
return {
label: "Checking",
className: "border-blue-300 text-blue-700",
icon: <Loader2 className="h-3.5 w-3.5 animate-spin" />,
};
}
if (!readiness) {
return {
label: "Not configured",
className: "border-muted text-muted-foreground",
icon: <AlertCircle className="h-3.5 w-3.5" />,
};
}
const ageMs = Date.now() - readiness.checkedAt;
if (ageMs > STALE_AFTER_MS) {
return {
label: "Stale",
className: "border-amber-300 text-amber-700",
icon: <AlertCircle className="h-3.5 w-3.5" />,
};
}
if (readiness.status === "ready") {
return {
label: "Ready",
className: "border-emerald-300 text-emerald-700",
icon: <CheckCircle2 className="h-3.5 w-3.5" />,
};
}
if (readiness.status === "unavailable") {
return {
label: "Unavailable",
className: "border-destructive/40 text-destructive",
icon: <AlertCircle className="h-3.5 w-3.5" />,
};
}
return {
label: "Not configured",
className: "border-muted text-muted-foreground",
icon: <AlertCircle className="h-3.5 w-3.5" />,
};
}
export const TracerLinksSettingsSection: React.FC<
TracerLinksSettingsSectionProps
> = ({ readiness, isLoading, isChecking, onVerifyNow }) => {
const statusUi = deriveStatus(readiness, isChecking);
const publicBaseUrl = readiness?.publicBaseUrl ?? null;
const checkTimestamp = readiness?.checkedAt ?? null;
return (
<AccordionItem value="tracer-links" className="border rounded-lg px-4">
<AccordionTrigger className="hover:no-underline py-4">
<span className="text-base font-semibold">Tracer Links</span>
</AccordionTrigger>
<AccordionContent className="pb-4">
<div className="space-y-4">
<div className="flex items-center justify-between gap-3 rounded-md border border-border/60 bg-muted/20 p-3">
<div className="flex items-center gap-2 text-sm">
{statusUi.icon}
<span className="font-medium">Readiness</span>
</div>
<Badge variant="outline" className={statusUi.className}>
{statusUi.label}
</Badge>
</div>
<div className="space-y-1">
<div className="text-xs font-medium text-muted-foreground">
Public URL
</div>
<div className="rounded-md border border-border/60 bg-background px-3 py-2 font-mono text-xs">
{publicBaseUrl ?? "Not configured"}
</div>
</div>
<div className="text-xs text-muted-foreground">
Last checked: {formatLastChecked(checkTimestamp)}
</div>
{readiness?.reason ? (
<div className="rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-xs text-destructive">
{readiness.reason}
</div>
) : null}
<div className="flex items-center justify-between gap-2">
<p className="text-xs text-muted-foreground">
Enable per-job tracer links only when status is Ready.
</p>
<Button
size="sm"
variant="outline"
onClick={() => void onVerifyNow()}
disabled={isLoading || isChecking}
>
{isChecking ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<RefreshCw className="mr-2 h-4 w-4" />
)}
Verify now
</Button>
</div>
</div>
</AccordionContent>
</AccordionItem>
);
};

View File

@ -15,6 +15,7 @@ import { postApplicationProvidersRouter } from "./routes/post-application-provid
import { postApplicationReviewRouter } from "./routes/post-application-review"; import { postApplicationReviewRouter } from "./routes/post-application-review";
import { profileRouter } from "./routes/profile"; import { profileRouter } from "./routes/profile";
import { settingsRouter } from "./routes/settings"; import { settingsRouter } from "./routes/settings";
import { tracerLinksRouter } from "./routes/tracer-links";
import { visaSponsorsRouter } from "./routes/visa-sponsors"; import { visaSponsorsRouter } from "./routes/visa-sponsors";
import { webhookRouter } from "./routes/webhook"; import { webhookRouter } from "./routes/webhook";
@ -34,3 +35,4 @@ apiRouter.use("/database", databaseRouter);
apiRouter.use("/visa-sponsors", visaSponsorsRouter); apiRouter.use("/visa-sponsors", visaSponsorsRouter);
apiRouter.use("/onboarding", onboardingRouter); apiRouter.use("/onboarding", onboardingRouter);
apiRouter.use("/backups", backupRouter); apiRouter.use("/backups", backupRouter);
apiRouter.use("/tracer-links", tracerLinksRouter);

View File

@ -175,6 +175,104 @@ describe.sequential("Jobs API routes", () => {
expect(typeof body.meta.requestId).toBe("string"); expect(typeof body.meta.requestId).toBe("string");
}); });
it("blocks enabling tracer links when readiness check fails", async () => {
const { createJob } = await import("../../repositories/jobs");
const job = await createJob({
source: "manual",
title: "Tracer Blocked",
employer: "Example Co",
jobUrl: "https://example.com/job/tracer-blocked",
jobDescription: "Test description",
});
const previousBaseUrl = process.env.JOBOPS_PUBLIC_BASE_URL;
process.env.JOBOPS_PUBLIC_BASE_URL = "https://my-jobops.example.com";
const realFetch = global.fetch;
const mockFetch = vi.fn(async (input: any, init?: RequestInit) => {
const url = typeof input === "string" ? input : input.toString();
if (url === "https://my-jobops.example.com/health") {
return new Response("unavailable", { status: 503 });
}
return realFetch(input, init);
});
vi.stubGlobal("fetch", mockFetch);
try {
const res = await fetch(`${baseUrl}/api/jobs/${job.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ tracerLinksEnabled: true }),
});
const body = await res.json();
expect(res.status).toBe(409);
expect(body.ok).toBe(false);
expect(body.error.code).toBe("CONFLICT");
expect(body.error.message).toMatch(/health check returned http 503/i);
expect(typeof body.meta.requestId).toBe("string");
} finally {
vi.unstubAllGlobals();
if (previousBaseUrl === undefined) {
delete process.env.JOBOPS_PUBLIC_BASE_URL;
} else {
process.env.JOBOPS_PUBLIC_BASE_URL = previousBaseUrl;
}
}
});
it("allows updates for already-enabled tracer links without re-gating", async () => {
const { createJob } = await import("../../repositories/jobs");
const { updateJob } = await import("../../repositories/jobs");
const job = await createJob({
source: "manual",
title: "Tracer Already On",
employer: "Example Co",
jobUrl: "https://example.com/job/tracer-enabled",
jobDescription: "Test description",
});
await updateJob(job.id, { tracerLinksEnabled: true });
const previousBaseUrl = process.env.JOBOPS_PUBLIC_BASE_URL;
process.env.JOBOPS_PUBLIC_BASE_URL = "https://my-jobops.example.com";
const realFetch = global.fetch;
const mockFetch = vi.fn(async (input: any, init?: RequestInit) => {
const url = typeof input === "string" ? input : input.toString();
if (url === "https://my-jobops.example.com/health") {
return new Response("unavailable", { status: 503 });
}
return realFetch(input, init);
});
vi.stubGlobal("fetch", mockFetch);
try {
const res = await fetch(`${baseUrl}/api/jobs/${job.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
title: "Tracer Already On (Edited)",
tracerLinksEnabled: true,
}),
});
const body = await res.json();
expect(res.status).toBe(200);
expect(body.ok).toBe(true);
expect(body.data.title).toBe("Tracer Already On (Edited)");
expect(body.data.tracerLinksEnabled).toBe(true);
expect(mockFetch).not.toHaveBeenCalledWith(
"https://my-jobops.example.com/health",
expect.anything(),
);
} finally {
vi.unstubAllGlobals();
if (previousBaseUrl === undefined) {
delete process.env.JOBOPS_PUBLIC_BASE_URL;
} else {
process.env.JOBOPS_PUBLIC_BASE_URL = previousBaseUrl;
}
}
});
it("returns 404 when patching a missing job", async () => { it("returns 404 when patching a missing job", async () => {
const res = await fetch(`${baseUrl}/api/jobs/missing-id`, { const res = await fetch(`${baseUrl}/api/jobs/missing-id`, {
method: "PATCH", method: "PATCH",
@ -189,6 +287,42 @@ describe.sequential("Jobs API routes", () => {
expect(typeof body.meta.requestId).toBe("string"); expect(typeof body.meta.requestId).toBe("string");
}); });
it("prefers JOBOPS_PUBLIC_BASE_URL over forwarded headers for generate-pdf origin", async () => {
const { createJob } = await import("../../repositories/jobs");
const { generateFinalPdf } = await import("../../pipeline/index");
const job = await createJob({
source: "manual",
title: "Origin Test",
employer: "Example Co",
jobUrl: "https://example.com/job/origin-test",
jobDescription: "Test description",
});
const previousBaseUrl = process.env.JOBOPS_PUBLIC_BASE_URL;
process.env.JOBOPS_PUBLIC_BASE_URL = "https://canonical.jobops.example";
try {
const res = await fetch(`${baseUrl}/api/jobs/${job.id}/generate-pdf`, {
method: "POST",
headers: {
"x-forwarded-proto": "http",
"x-forwarded-host": "attacker.example",
},
});
expect(res.status).toBe(200);
expect(vi.mocked(generateFinalPdf)).toHaveBeenCalledWith(job.id, {
requestOrigin: "https://canonical.jobops.example",
});
} finally {
if (previousBaseUrl === undefined) {
delete process.env.JOBOPS_PUBLIC_BASE_URL;
} else {
process.env.JOBOPS_PUBLIC_BASE_URL = previousBaseUrl;
}
}
});
it("returns 409 when patching to a duplicate job URL", async () => { it("returns 409 when patching to a duplicate job URL", async () => {
const { createJob } = await import("../../repositories/jobs"); const { createJob } = await import("../../repositories/jobs");
const first = await createJob({ const first = await createJob({

View File

@ -43,6 +43,7 @@ import {
} from "../../services/demo-simulator"; } from "../../services/demo-simulator";
import { getProfile } from "../../services/profile"; import { getProfile } from "../../services/profile";
import { scoreJobSuitability } from "../../services/scorer"; import { scoreJobSuitability } from "../../services/scorer";
import { getTracerReadiness } from "../../services/tracer-links";
import * as visaSponsors from "../../services/visa-sponsors/index"; import * as visaSponsors from "../../services/visa-sponsors/index";
export const jobsRouter = Router(); export const jobsRouter = Router();
@ -163,6 +164,7 @@ const updateJobSchema = z.object({
}), }),
selectedProjectIds: z.string().optional(), selectedProjectIds: z.string().optional(),
pdfPath: z.string().optional(), pdfPath: z.string().optional(),
tracerLinksEnabled: z.boolean().optional(),
sponsorMatchScore: z.number().min(0).max(100).optional(), sponsorMatchScore: z.number().min(0).max(100).optional(),
sponsorMatchNames: z.string().optional(), sponsorMatchNames: z.string().optional(),
}); });
@ -217,6 +219,36 @@ function parseStatusFilter(statusFilter?: string): JobStatus[] | undefined {
return parsed && parsed.length > 0 ? parsed : undefined; return parsed && parsed.length > 0 ? parsed : undefined;
} }
function resolveRequestOrigin(req: Request): string | null {
const configuredBaseUrl = process.env.JOBOPS_PUBLIC_BASE_URL?.trim();
if (configuredBaseUrl) {
try {
const parsed = new URL(configuredBaseUrl);
if (parsed.protocol && parsed.host) {
return `${parsed.protocol}//${parsed.host}`;
}
} catch {
// Ignore invalid env and fall back to request-derived origin.
}
}
const trustProxy = Boolean(req.app?.get("trust proxy"));
let protocol = (req.protocol || "").trim();
let host = (req.header("host") || "").trim();
if (trustProxy) {
const forwardedProto =
req.header("x-forwarded-proto")?.split(",")[0]?.trim() ?? "";
const forwardedHost =
req.header("x-forwarded-host")?.split(",")[0]?.trim() ?? "";
if (forwardedProto) protocol = forwardedProto;
if (forwardedHost) host = forwardedHost;
}
if (!host || !protocol) return null;
return `${protocol}://${host}`;
}
function mapErrorForResult(error: unknown): { function mapErrorForResult(error: unknown): {
code: string; code: string;
message: string; message: string;
@ -832,6 +864,51 @@ jobsRouter.patch("/:id/outcome", async (req: Request, res: Response) => {
jobsRouter.patch("/:id", async (req: Request, res: Response) => { jobsRouter.patch("/:id", async (req: Request, res: Response) => {
try { try {
const input = updateJobSchema.parse(req.body); const input = updateJobSchema.parse(req.body);
const currentJob = await jobsRepo.getJobById(req.params.id);
if (!currentJob) {
const err = new AppError({
status: 404,
code: "NOT_FOUND",
message: "Job not found",
});
logger.warn("Job update failed", {
route: "PATCH /api/jobs/:id",
jobId: req.params.id,
status: err.status,
code: err.code,
});
fail(res, err);
return;
}
const isTurningTracerLinksOn =
input.tracerLinksEnabled === true && !currentJob.tracerLinksEnabled;
if (isTurningTracerLinksOn) {
const readiness = await getTracerReadiness({
requestOrigin: resolveRequestOrigin(req),
force: true,
});
if (!readiness.canEnable) {
throw new AppError({
status: 409,
code: "CONFLICT",
message:
readiness.reason ??
"Tracer links are unavailable right now. Verify Tracer Links in Settings.",
details: {
tracerReadiness: {
status: readiness.status,
checkedAt: readiness.checkedAt,
publicBaseUrl: readiness.publicBaseUrl,
},
},
});
}
}
const job = await jobsRepo.updateJob(req.params.id, input); const job = await jobsRepo.updateJob(req.params.id, input);
if (!job) { if (!job) {
@ -1031,7 +1108,9 @@ jobsRouter.post("/:id/generate-pdf", async (req: Request, res: Response) => {
return okWithMeta(res, job, { simulated: true }); return okWithMeta(res, job, { simulated: true });
} }
const result = await generateFinalPdf(req.params.id); const result = await generateFinalPdf(req.params.id, {
requestOrigin: resolveRequestOrigin(req),
});
if (!result.success) { if (!result.success) {
return res.status(400).json({ success: false, error: result.error }); return res.status(400).json({ success: false, error: result.error });
@ -1065,7 +1144,10 @@ jobsRouter.post("/:id/process", async (req: Request, res: Response) => {
return okWithMeta(res, job, { simulated: true }); return okWithMeta(res, job, { simulated: true });
} }
const result = await processJob(req.params.id, { force }); const result = await processJob(req.params.id, {
force,
requestOrigin: resolveRequestOrigin(req),
});
if (!result.success) { if (!result.success) {
return res.status(400).json({ success: false, error: result.error }); return res.status(400).json({ success: false, error: result.error });

View File

@ -0,0 +1,246 @@
import { randomUUID } from "node:crypto";
import type { Server } from "node:http";
import { and, eq } from "drizzle-orm";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { startServer, stopServer } from "./test-utils";
describe.sequential("Tracer links routes", () => {
let server: Server;
let baseUrl: string;
let closeDb: () => void;
let tempDir: string;
beforeEach(async () => {
({ server, baseUrl, closeDb, tempDir } = await startServer());
});
afterEach(async () => {
await stopServer({ server, closeDb, tempDir });
});
async function seedTracerFixtures() {
const { db, schema } = await import("../../db");
const now = new Date().toISOString();
const jobId = "job-tracer-fixture";
const tracerLinkId = "link-tracer-fixture";
const token = "tok-tracer-fixture";
await db.insert(schema.jobs).values({
id: jobId,
source: "manual",
title: "Staff Engineer",
employer: "Example Corp",
jobUrl: "https://example.com/jobs/staff-engineer",
tracerLinksEnabled: true,
createdAt: now,
updatedAt: now,
discoveredAt: now,
});
await db.insert(schema.tracerLinks).values({
id: tracerLinkId,
token,
jobId,
sourcePath: "basics.url.href",
sourceLabel: "Portfolio",
destinationUrl: "https://github.com/example",
destinationUrlHash: "hash-github",
isActive: true,
createdAt: now,
updatedAt: now,
});
return { db, schema, jobId, tracerLinkId, token };
}
it("redirects a valid token and records click event", async () => {
const { db, schema, tracerLinkId, token } = await seedTracerFixtures();
const res = await fetch(`${baseUrl}/cv/${token}`, {
redirect: "manual",
headers: {
"user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X)",
referer: "https://mail.example.com/inbox",
},
});
expect(res.status).toBe(302);
expect(res.headers.get("location")).toBe("https://github.com/example");
expect(res.headers.get("cache-control")).toBe("no-store");
expect(res.headers.get("pragma")).toBe("no-cache");
expect(res.headers.get("expires")).toBe("0");
const clickRows = await db
.select()
.from(schema.tracerClickEvents)
.where(eq(schema.tracerClickEvents.tracerLinkId, tracerLinkId));
expect(clickRows.length).toBe(1);
expect(clickRows[0]?.ipHash).toBeTruthy();
expect(clickRows[0]?.uniqueFingerprintHash).toBeTruthy();
expect(clickRows[0]?.referrerHost).toBe("mail.example.com");
});
it("returns 404 for unknown tracer token", async () => {
const res = await fetch(`${baseUrl}/cv/does-not-exist`, {
redirect: "manual",
});
expect(res.status).toBe(404);
});
it("returns analytics contract and supports filters", async () => {
const { db, schema, jobId, tracerLinkId } = await seedTracerFixtures();
const now = Math.floor(Date.now() / 1000);
await db.insert(schema.tracerClickEvents).values([
{
id: randomUUID(),
tracerLinkId,
clickedAt: now - 60,
requestId: "req-human-1",
isLikelyBot: false,
deviceType: "desktop",
uaFamily: "chrome",
osFamily: "macos",
uniqueFingerprintHash: "fp-a",
},
{
id: randomUUID(),
tracerLinkId,
clickedAt: now - 30,
requestId: "req-bot-1",
isLikelyBot: true,
deviceType: "desktop",
uaFamily: "bot",
osFamily: "unknown",
uniqueFingerprintHash: "fp-b",
},
]);
const analyticsRes = await fetch(
`${baseUrl}/api/tracer-links/analytics?jobId=${jobId}&includeBots=0&from=${now - 3600}&to=${now}`,
);
expect(analyticsRes.status).toBe(200);
const analyticsBody = (await analyticsRes.json()) as {
ok: boolean;
data?: {
totals: { clicks: number; uniqueOpens: number; botClicks: number };
};
meta?: { requestId?: string };
};
expect(analyticsBody.ok).toBe(true);
expect(analyticsBody.meta?.requestId).toBeTruthy();
expect(analyticsBody.data?.totals.clicks).toBe(1);
expect(analyticsBody.data?.totals.uniqueOpens).toBe(1);
expect(analyticsBody.data?.totals.botClicks).toBe(0);
const jobRes = await fetch(
`${baseUrl}/api/tracer-links/jobs/${jobId}?includeBots=1`,
);
expect(jobRes.status).toBe(200);
const jobBody = (await jobRes.json()) as {
ok: boolean;
data?: {
job: { id: string };
links: Array<{
tracerLinkId: string;
clicks: number;
botClicks: number;
}>;
};
meta?: { requestId?: string };
};
expect(jobBody.ok).toBe(true);
expect(jobBody.meta?.requestId).toBeTruthy();
expect(jobBody.data?.job.id).toBe(jobId);
const row = jobBody.data?.links.find(
(item) => item.tracerLinkId === tracerLinkId,
);
expect(row).toBeTruthy();
expect(row?.clicks).toBe(2);
expect(row?.botClicks).toBe(1);
const persistedEvents = await db
.select()
.from(schema.tracerClickEvents)
.where(
and(
eq(schema.tracerClickEvents.tracerLinkId, tracerLinkId),
eq(schema.tracerClickEvents.isLikelyBot, true),
),
);
expect(persistedEvents.length).toBe(1);
});
it("returns tracer readiness contract", async () => {
const realFetch = global.fetch;
const healthUrl = "https://my-jobops.example.com/health";
const mockFetch = vi.fn(async (input: any, init?: RequestInit) => {
const url = typeof input === "string" ? input : input.toString();
if (url === healthUrl) {
return new Response(JSON.stringify({ status: "ok" }), {
status: 200,
headers: { "content-type": "application/json" },
});
}
return realFetch(input, init);
});
const previousBaseUrl = process.env.JOBOPS_PUBLIC_BASE_URL;
process.env.JOBOPS_PUBLIC_BASE_URL = "https://my-jobops.example.com";
vi.stubGlobal("fetch", mockFetch);
try {
const res = await fetch(`${baseUrl}/api/tracer-links/readiness?force=1`);
expect(res.status).toBe(200);
const body = (await res.json()) as {
ok: boolean;
data?: {
status: string;
canEnable: boolean;
publicBaseUrl: string | null;
};
meta?: { requestId?: string };
};
expect(body.ok).toBe(true);
expect(body.meta?.requestId).toBeTruthy();
expect(body.data?.status).toBe("ready");
expect(body.data?.canEnable).toBe(true);
expect(body.data?.publicBaseUrl).toBe("https://my-jobops.example.com");
} finally {
vi.unstubAllGlobals();
if (previousBaseUrl === undefined) {
delete process.env.JOBOPS_PUBLIC_BASE_URL;
} else {
process.env.JOBOPS_PUBLIC_BASE_URL = previousBaseUrl;
}
}
});
it("requires auth for tracer analytics GET routes when basic auth is enabled", async () => {
await stopServer({ server, closeDb, tempDir });
({ server, baseUrl, closeDb, tempDir } = await startServer({
env: {
BASIC_AUTH_USER: "admin",
BASIC_AUTH_PASSWORD: "secret",
},
}));
const unauthorized = await fetch(`${baseUrl}/api/tracer-links/analytics`);
expect(unauthorized.status).toBe(401);
const credentials = Buffer.from("admin:secret").toString("base64");
const authorized = await fetch(`${baseUrl}/api/tracer-links/analytics`, {
headers: {
Authorization: `Basic ${credentials}`,
},
});
expect(authorized.status).toBe(200);
});
});

View File

@ -0,0 +1,177 @@
import { badRequest, notFound } from "@infra/errors";
import { asyncRoute, fail, ok } from "@infra/http";
import { type Request, type Response, Router } from "express";
import { z } from "zod";
import * as jobsRepo from "../../repositories/jobs";
import {
getJobTracerLinksAnalytics,
getTracerAnalytics,
getTracerReadiness,
} from "../../services/tracer-links";
export const tracerLinksRouter = Router();
const querySchema = z.object({
jobId: z.string().trim().min(1).max(255).optional(),
from: z.coerce.number().int().min(0).optional(),
to: z.coerce.number().int().min(0).optional(),
includeBots: z
.preprocess((value) => {
if (value === undefined) return false;
if (typeof value === "boolean") return value;
const lowered = String(value).trim().toLowerCase();
return lowered === "1" || lowered === "true" || lowered === "yes";
}, z.boolean())
.optional(),
limit: z.coerce.number().int().min(1).max(500).optional(),
});
const paramsSchema = z.object({
jobId: z.string().trim().min(1).max(255),
});
const readinessQuerySchema = z.object({
force: z
.preprocess((value) => {
if (value === undefined) return false;
if (typeof value === "boolean") return value;
const lowered = String(value).trim().toLowerCase();
return lowered === "1" || lowered === "true" || lowered === "yes";
}, z.boolean())
.optional(),
});
function assertTimeRange(
from: number | undefined,
to: number | undefined,
): string | null {
if (typeof from === "number" && typeof to === "number" && from > to) {
return "`from` must be less than or equal to `to`.";
}
return null;
}
function resolveRequestOrigin(req: Request): string | null {
const configuredBaseUrl = process.env.JOBOPS_PUBLIC_BASE_URL?.trim();
if (configuredBaseUrl) {
try {
const parsed = new URL(configuredBaseUrl);
if (parsed.protocol && parsed.host) {
return `${parsed.protocol}//${parsed.host}`;
}
} catch {
// Ignore invalid env and fall back to request-derived origin.
}
}
const trustProxy = Boolean(req.app?.get("trust proxy"));
let protocol = (req.protocol || "").trim();
let host = (req.header("host") || "").trim();
if (trustProxy) {
const forwardedProto =
req.header("x-forwarded-proto")?.split(",")[0]?.trim() ?? "";
const forwardedHost =
req.header("x-forwarded-host")?.split(",")[0]?.trim() ?? "";
if (forwardedProto) protocol = forwardedProto;
if (forwardedHost) host = forwardedHost;
}
if (!host || !protocol) return null;
return `${protocol}://${host}`;
}
tracerLinksRouter.get(
"/readiness",
asyncRoute(async (req: Request, res: Response) => {
const parsed = readinessQuerySchema.safeParse(req.query);
if (!parsed.success) {
fail(res, badRequest(parsed.error.message, parsed.error.flatten()));
return;
}
const readiness = await getTracerReadiness({
requestOrigin: resolveRequestOrigin(req),
force: parsed.data.force ?? false,
});
ok(res, readiness);
}),
);
tracerLinksRouter.get(
"/analytics",
asyncRoute(async (req: Request, res: Response) => {
const parsed = querySchema.safeParse(req.query);
if (!parsed.success) {
fail(res, badRequest(parsed.error.message, parsed.error.flatten()));
return;
}
const rangeError = assertTimeRange(parsed.data.from, parsed.data.to);
if (rangeError) {
fail(res, badRequest(rangeError));
return;
}
const analytics = await getTracerAnalytics({
jobId: parsed.data.jobId ?? null,
from: parsed.data.from ?? null,
to: parsed.data.to ?? null,
includeBots: parsed.data.includeBots ?? false,
limit: parsed.data.limit ?? 20,
});
ok(res, analytics);
}),
);
tracerLinksRouter.get(
"/jobs/:jobId",
asyncRoute(async (req: Request, res: Response) => {
const parsedParams = paramsSchema.safeParse(req.params);
if (!parsedParams.success) {
fail(
res,
badRequest(parsedParams.error.message, parsedParams.error.flatten()),
);
return;
}
const parsedQuery = querySchema.safeParse(req.query);
if (!parsedQuery.success) {
fail(
res,
badRequest(parsedQuery.error.message, parsedQuery.error.flatten()),
);
return;
}
const rangeError = assertTimeRange(
parsedQuery.data.from,
parsedQuery.data.to,
);
if (rangeError) {
fail(res, badRequest(rangeError));
return;
}
const job = await jobsRepo.getJobById(parsedParams.data.jobId);
if (!job) {
fail(res, notFound("Job not found"));
return;
}
const analytics = await getJobTracerLinksAnalytics({
jobId: job.id,
title: job.title,
employer: job.employer,
tracerLinksEnabled: job.tracerLinksEnabled,
from: parsedQuery.data.from ?? null,
to: parsedQuery.data.to ?? null,
includeBots: parsedQuery.data.includeBots ?? false,
});
ok(res, analytics);
}),
);

View File

@ -20,6 +20,7 @@ import express from "express";
import { apiRouter } from "./api/index"; import { apiRouter } from "./api/index";
import { getDataDir } from "./config/dataDir"; import { getDataDir } from "./config/dataDir";
import { isDemoMode } from "./config/demo"; import { isDemoMode } from "./config/demo";
import { resolveTracerRedirect } from "./services/tracer-links";
const __dirname = dirname(fileURLToPath(import.meta.url)); const __dirname = dirname(fileURLToPath(import.meta.url));
@ -66,6 +67,9 @@ function createBasicAuthGuard() {
function requiresAuth(method: string, path: string): boolean { function requiresAuth(method: string, path: string): boolean {
if (isPublicReadOnlyRoute(method, path)) return false; if (isPublicReadOnlyRoute(method, path)) return false;
if (path.startsWith("/api/tracer-links")) {
return method.toUpperCase() !== "OPTIONS";
}
return !["GET", "HEAD", "OPTIONS"].includes(method.toUpperCase()); return !["GET", "HEAD", "OPTIONS"].includes(method.toUpperCase());
} }
@ -91,6 +95,50 @@ export function createApp() {
const app = express(); const app = express();
const authGuard = createBasicAuthGuard(); const authGuard = createBasicAuthGuard();
const handleTracerRedirect = async (
req: express.Request,
res: express.Response,
slug: string,
route: string,
) => {
try {
const redirect = await resolveTracerRedirect({
token: slug,
requestId:
(res.getHeader("x-request-id") as string | undefined) ?? null,
ip: req.ip ?? null,
userAgent: req.header("user-agent") ?? null,
referrer: req.header("referer") ?? null,
});
if (!redirect) {
logger.warn("Tracer link not found", {
route,
token: slug,
});
res.status(404).type("text/plain; charset=utf-8").send("Not found");
return;
}
logger.info("Tracer link redirected", {
route,
token: slug,
jobId: redirect.jobId,
});
res.set("Cache-Control", "no-store");
res.set("Pragma", "no-cache");
res.set("Expires", "0");
res.redirect(302, redirect.destinationUrl);
} catch (error) {
logger.error("Tracer redirect failed", {
route,
token: slug,
error,
});
res.status(500).type("text/plain; charset=utf-8").send("Internal error");
}
};
app.use(cors()); app.use(cors());
app.use(requestContextMiddleware()); app.use(requestContextMiddleware());
app.use(express.json({ limit: "5mb" })); app.use(express.json({ limit: "5mb" }));
@ -118,6 +166,15 @@ export function createApp() {
app.use("/api", apiRouter); app.use("/api", apiRouter);
app.use(notFoundApiHandler()); app.use(notFoundApiHandler());
app.get("/cv/:slug", async (req, res) => {
const slug = req.params.slug?.trim();
if (!slug) {
res.status(404).type("text/plain; charset=utf-8").send("Not found");
return;
}
await handleTracerRedirect(req, res, slug, "GET /cv/:slug");
});
// Serve static files for generated PDFs // Serve static files for generated PDFs
const pdfDir = join(getDataDir(), "pdfs"); const pdfDir = join(getDataDir(), "pdfs");
if (isDemoMode()) { if (isDemoMode()) {

View File

@ -67,7 +67,11 @@ const migrations = [
suitability_score REAL, suitability_score REAL,
suitability_reason TEXT, suitability_reason TEXT,
tailored_summary TEXT, tailored_summary TEXT,
tailored_headline TEXT,
tailored_skills TEXT,
selected_project_ids TEXT,
pdf_path TEXT, pdf_path TEXT,
tracer_links_enabled INTEGER NOT NULL DEFAULT 0,
discovered_at TEXT NOT NULL DEFAULT (datetime('now')), discovered_at TEXT NOT NULL DEFAULT (datetime('now')),
processed_at TEXT, processed_at TEXT,
applied_at TEXT, applied_at TEXT,
@ -244,6 +248,36 @@ const migrations = [
UNIQUE(provider, account_key, external_message_id) UNIQUE(provider, account_key, external_message_id)
)`, )`,
`CREATE TABLE IF NOT EXISTS tracer_links (
id TEXT PRIMARY KEY,
token TEXT NOT NULL UNIQUE,
job_id TEXT NOT NULL,
source_path TEXT NOT NULL,
source_label TEXT NOT NULL,
destination_url TEXT NOT NULL,
destination_url_hash TEXT NOT NULL,
is_active INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (job_id) REFERENCES jobs(id) ON DELETE CASCADE,
UNIQUE(job_id, source_path, destination_url_hash)
)`,
`CREATE TABLE IF NOT EXISTS tracer_click_events (
id TEXT PRIMARY KEY,
tracer_link_id TEXT NOT NULL,
clicked_at INTEGER NOT NULL,
request_id TEXT,
is_likely_bot INTEGER NOT NULL DEFAULT 0,
device_type TEXT NOT NULL DEFAULT 'unknown',
ua_family TEXT NOT NULL DEFAULT 'unknown',
os_family TEXT NOT NULL DEFAULT 'unknown',
referrer_host TEXT,
ip_hash TEXT,
unique_fingerprint_hash TEXT,
FOREIGN KEY (tracer_link_id) REFERENCES tracer_links(id) ON DELETE CASCADE
)`,
// Rename settings key: webhookUrl -> pipelineWebhookUrl (safe to re-run) // Rename settings key: webhookUrl -> pipelineWebhookUrl (safe to re-run)
`INSERT OR REPLACE INTO settings(key, value, created_at, updated_at) `INSERT OR REPLACE INTO settings(key, value, created_at, updated_at)
SELECT 'pipelineWebhookUrl', value, created_at, updated_at FROM settings WHERE key = 'webhookUrl'`, SELECT 'pipelineWebhookUrl', value, created_at, updated_at FROM settings WHERE key = 'webhookUrl'`,
@ -293,6 +327,7 @@ const migrations = [
`ALTER TABLE jobs ADD COLUMN selected_project_ids TEXT`, `ALTER TABLE jobs ADD COLUMN selected_project_ids TEXT`,
`ALTER TABLE jobs ADD COLUMN tailored_headline TEXT`, `ALTER TABLE jobs ADD COLUMN tailored_headline TEXT`,
`ALTER TABLE jobs ADD COLUMN tailored_skills TEXT`, `ALTER TABLE jobs ADD COLUMN tailored_skills TEXT`,
`ALTER TABLE jobs ADD COLUMN tracer_links_enabled INTEGER NOT NULL DEFAULT 0`,
// Add sponsor match columns for visa sponsor matching feature // Add sponsor match columns for visa sponsor matching feature
`ALTER TABLE jobs ADD COLUMN sponsor_match_score REAL`, `ALTER TABLE jobs ADD COLUMN sponsor_match_score REAL`,
@ -403,6 +438,7 @@ const migrations = [
tailored_skills TEXT, tailored_skills TEXT,
selected_project_ids TEXT, selected_project_ids TEXT,
pdf_path TEXT, pdf_path TEXT,
tracer_links_enabled INTEGER NOT NULL DEFAULT 0,
sponsor_match_score REAL, sponsor_match_score REAL,
sponsor_match_names TEXT, sponsor_match_names TEXT,
discovered_at TEXT NOT NULL DEFAULT (datetime('now')), discovered_at TEXT NOT NULL DEFAULT (datetime('now')),
@ -419,7 +455,7 @@ const migrations = [
vacancy_count, work_from_home_type, title, employer, employer_url, job_url, application_link, disciplines, vacancy_count, work_from_home_type, title, employer, employer_url, job_url, application_link, disciplines,
deadline, salary, location, degree_required, starting, job_description, status, outcome, closed_at, deadline, salary, location, degree_required, starting, job_description, status, outcome, closed_at,
suitability_score, suitability_reason, tailored_summary, tailored_headline, tailored_skills, suitability_score, suitability_reason, tailored_summary, tailored_headline, tailored_skills,
selected_project_ids, pdf_path, sponsor_match_score, sponsor_match_names, discovered_at, processed_at, selected_project_ids, pdf_path, tracer_links_enabled, sponsor_match_score, sponsor_match_names, discovered_at, processed_at,
applied_at, created_at, updated_at applied_at, created_at, updated_at
) )
SELECT SELECT
@ -430,7 +466,7 @@ const migrations = [
vacancy_count, work_from_home_type, title, employer, employer_url, job_url, application_link, disciplines, vacancy_count, work_from_home_type, title, employer, employer_url, job_url, application_link, disciplines,
deadline, salary, location, degree_required, starting, job_description, status, outcome, closed_at, deadline, salary, location, degree_required, starting, job_description, status, outcome, closed_at,
suitability_score, suitability_reason, tailored_summary, tailored_headline, tailored_skills, suitability_score, suitability_reason, tailored_summary, tailored_headline, tailored_skills,
selected_project_ids, pdf_path, sponsor_match_score, sponsor_match_names, discovered_at, processed_at, selected_project_ids, pdf_path, tracer_links_enabled, sponsor_match_score, sponsor_match_names, discovered_at, processed_at,
applied_at, created_at, updated_at applied_at, created_at, updated_at
FROM jobs`, FROM jobs`,
`DROP TABLE IF EXISTS jobs`, `DROP TABLE IF EXISTS jobs`,
@ -451,6 +487,12 @@ const migrations = [
`CREATE INDEX IF NOT EXISTS idx_job_chat_threads_job_updated ON job_chat_threads(job_id, updated_at)`, `CREATE INDEX IF NOT EXISTS idx_job_chat_threads_job_updated ON job_chat_threads(job_id, updated_at)`,
`CREATE INDEX IF NOT EXISTS idx_job_chat_messages_thread_created ON job_chat_messages(thread_id, created_at)`, `CREATE INDEX IF NOT EXISTS idx_job_chat_messages_thread_created ON job_chat_messages(thread_id, created_at)`,
`CREATE INDEX IF NOT EXISTS idx_job_chat_runs_thread_status ON job_chat_runs(thread_id, status)`, `CREATE INDEX IF NOT EXISTS idx_job_chat_runs_thread_status ON job_chat_runs(thread_id, status)`,
`CREATE INDEX IF NOT EXISTS idx_tracer_links_token ON tracer_links(token)`,
`CREATE INDEX IF NOT EXISTS idx_tracer_links_job_id ON tracer_links(job_id)`,
`CREATE INDEX IF NOT EXISTS idx_tracer_click_events_tracer_link_id ON tracer_click_events(tracer_link_id)`,
`CREATE INDEX IF NOT EXISTS idx_tracer_click_events_clicked_at ON tracer_click_events(clicked_at)`,
`CREATE INDEX IF NOT EXISTS idx_tracer_click_events_is_likely_bot ON tracer_click_events(is_likely_bot)`,
`CREATE INDEX IF NOT EXISTS idx_tracer_click_events_unique_fingerprint_hash ON tracer_click_events(unique_fingerprint_hash)`,
// Ensure only one running run per thread; backfill any duplicates first. // Ensure only one running run per thread; backfill any duplicates first.
`WITH ranked AS ( `WITH ranked AS (
SELECT SELECT

View File

@ -110,6 +110,9 @@ export const jobs = sqliteTable("jobs", {
tailoredSkills: text("tailored_skills"), tailoredSkills: text("tailored_skills"),
selectedProjectIds: text("selected_project_ids"), selectedProjectIds: text("selected_project_ids"),
pdfPath: text("pdf_path"), pdfPath: text("pdf_path"),
tracerLinksEnabled: integer("tracer_links_enabled", { mode: "boolean" })
.notNull()
.default(false),
sponsorMatchScore: real("sponsor_match_score"), sponsorMatchScore: real("sponsor_match_score"),
sponsorMatchNames: text("sponsor_match_names"), sponsorMatchNames: text("sponsor_match_names"),
@ -385,6 +388,65 @@ export const postApplicationMessages = sqliteTable(
}), }),
); );
export const tracerLinks = sqliteTable(
"tracer_links",
{
id: text("id").primaryKey(),
token: text("token").notNull().unique(),
jobId: text("job_id")
.notNull()
.references(() => jobs.id, { onDelete: "cascade" }),
sourcePath: text("source_path").notNull(),
sourceLabel: text("source_label").notNull(),
destinationUrl: text("destination_url").notNull(),
destinationUrlHash: text("destination_url_hash").notNull(),
isActive: integer("is_active", { mode: "boolean" }).notNull().default(true),
createdAt: text("created_at").notNull().default(sql`(datetime('now'))`),
updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`),
},
(table) => ({
jobPathDestinationUnique: uniqueIndex(
"idx_tracer_links_job_source_destination_unique",
).on(table.jobId, table.sourcePath, table.destinationUrlHash),
jobIndex: index("idx_tracer_links_job_id").on(table.jobId),
}),
);
export const tracerClickEvents = sqliteTable(
"tracer_click_events",
{
id: text("id").primaryKey(),
tracerLinkId: text("tracer_link_id")
.notNull()
.references(() => tracerLinks.id, { onDelete: "cascade" }),
clickedAt: integer("clicked_at", { mode: "number" }).notNull(),
requestId: text("request_id"),
isLikelyBot: integer("is_likely_bot", { mode: "boolean" })
.notNull()
.default(false),
deviceType: text("device_type").notNull().default("unknown"),
uaFamily: text("ua_family").notNull().default("unknown"),
osFamily: text("os_family").notNull().default("unknown"),
referrerHost: text("referrer_host"),
ipHash: text("ip_hash"),
uniqueFingerprintHash: text("unique_fingerprint_hash"),
},
(table) => ({
tracerLinkIndex: index("idx_tracer_click_events_tracer_link_id").on(
table.tracerLinkId,
),
clickedAtIndex: index("idx_tracer_click_events_clicked_at").on(
table.clickedAt,
),
botIndex: index("idx_tracer_click_events_is_likely_bot").on(
table.isLikelyBot,
),
uniqueFingerprintIndex: index(
"idx_tracer_click_events_unique_fingerprint_hash",
).on(table.uniqueFingerprintHash),
}),
);
export type JobRow = typeof jobs.$inferSelect; export type JobRow = typeof jobs.$inferSelect;
export type NewJobRow = typeof jobs.$inferInsert; export type NewJobRow = typeof jobs.$inferInsert;
export type StageEventRow = typeof stageEvents.$inferSelect; export type StageEventRow = typeof stageEvents.$inferSelect;
@ -415,3 +477,7 @@ export type PostApplicationMessageRow =
typeof postApplicationMessages.$inferSelect; typeof postApplicationMessages.$inferSelect;
export type NewPostApplicationMessageRow = export type NewPostApplicationMessageRow =
typeof postApplicationMessages.$inferInsert; typeof postApplicationMessages.$inferInsert;
export type TracerLinkRow = typeof tracerLinks.$inferSelect;
export type NewTracerLinkRow = typeof tracerLinks.$inferInsert;
export type TracerClickEventRow = typeof tracerClickEvents.$inferSelect;
export type NewTracerClickEventRow = typeof tracerClickEvents.$inferInsert;

View File

@ -223,6 +223,7 @@ export async function runPipeline(
export type ProcessJobOptions = { export type ProcessJobOptions = {
force?: boolean; force?: boolean;
requestOrigin?: string | null;
}; };
/** /**
@ -323,7 +324,7 @@ export async function summarizeJob(
*/ */
export async function generateFinalPdf( export async function generateFinalPdf(
jobId: string, jobId: string,
_options?: ProcessJobOptions, options?: ProcessJobOptions,
): Promise<{ ): Promise<{
success: boolean; success: boolean;
error?: string; error?: string;
@ -348,6 +349,11 @@ export async function generateFinalPdf(
job.jobDescription || "", job.jobDescription || "",
undefined, // deprecated baseResumePath parameter undefined, // deprecated baseResumePath parameter
job.selectedProjectIds, job.selectedProjectIds,
{
tracerLinksEnabled: job.tracerLinksEnabled,
requestOrigin: options?.requestOrigin ?? null,
tracerCompanyName: job.employer ?? null,
},
); );
if (!pdfResult.success) { if (!pdfResult.success) {

View File

@ -399,6 +399,7 @@ function mapRowToJob(row: typeof jobs.$inferSelect): Job {
tailoredSkills: row.tailoredSkills ?? null, tailoredSkills: row.tailoredSkills ?? null,
selectedProjectIds: row.selectedProjectIds ?? null, selectedProjectIds: row.selectedProjectIds ?? null,
pdfPath: row.pdfPath, pdfPath: row.pdfPath,
tracerLinksEnabled: row.tracerLinksEnabled ?? false,
sponsorMatchScore: row.sponsorMatchScore ?? null, sponsorMatchScore: row.sponsorMatchScore ?? null,
sponsorMatchNames: row.sponsorMatchNames ?? null, sponsorMatchNames: row.sponsorMatchNames ?? null,
jobType: row.jobType ?? null, jobType: row.jobType ?? null,

View File

@ -0,0 +1,92 @@
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
describe.sequential("tracer-links repository", () => {
const originalEnv = { ...process.env };
let tempDir = "";
let closeDb: (() => void) | null = null;
beforeEach(async () => {
vi.resetModules();
tempDir = await mkdtemp(join(tmpdir(), "job-ops-tracer-repo-test-"));
process.env = {
...originalEnv,
DATA_DIR: tempDir,
NODE_ENV: "test",
};
await import("../db/migrate");
const dbModule = await import("../db");
closeDb = dbModule.closeDb;
await dbModule.db.insert(dbModule.schema.jobs).values({
id: "job-tracer-1",
source: "manual",
title: "Backend Engineer",
employer: "Acme",
jobUrl: "https://example.com/jobs/1",
});
});
afterEach(async () => {
closeDb?.();
closeDb = null;
if (tempDir) {
await rm(tempDir, { recursive: true, force: true });
}
process.env = { ...originalEnv };
});
it("reuses token for same job + source path + destination hash", async () => {
const repo = await import("./tracer-links");
const first = await repo.getOrCreateTracerLink({
jobId: "job-tracer-1",
sourcePath: "basics.url.href",
sourceLabel: "Portfolio",
destinationUrl: "https://example.com/portfolio",
destinationUrlHash: "hash-a",
slugPrefix: "sarfaraz-amazon",
});
const second = await repo.getOrCreateTracerLink({
jobId: "job-tracer-1",
sourcePath: "basics.url.href",
sourceLabel: "Portfolio",
destinationUrl: "https://example.com/portfolio",
destinationUrlHash: "hash-a",
slugPrefix: "sarfaraz-amazon",
});
expect(second.id).toBe(first.id);
expect(second.token).toBe(first.token);
expect(first.token).toMatch(/^sarfaraz-amazon-[a-z]{2}$/);
});
it("creates a new token when destination changes for same source path", async () => {
const repo = await import("./tracer-links");
const first = await repo.getOrCreateTracerLink({
jobId: "job-tracer-1",
sourcePath: "basics.url.href",
sourceLabel: "Portfolio",
destinationUrl: "https://example.com/portfolio-v1",
destinationUrlHash: "hash-v1",
slugPrefix: "sarfaraz-amazon",
});
const second = await repo.getOrCreateTracerLink({
jobId: "job-tracer-1",
sourcePath: "basics.url.href",
sourceLabel: "Portfolio",
destinationUrl: "https://example.com/portfolio-v2",
destinationUrlHash: "hash-v2",
slugPrefix: "sarfaraz-amazon",
});
expect(second.id).not.toBe(first.id);
expect(second.token).not.toBe(first.token);
});
});

View File

@ -0,0 +1,494 @@
import { createId } from "@paralleldrive/cuid2";
import { and, desc, eq, gte, lte, sql } from "drizzle-orm";
import { db, schema } from "../db";
const { jobs, tracerClickEvents, tracerLinks } = schema;
const TRACE_CODE_ALPHABET = "abcdefghijklmnopqrstuvwxyz";
const TRACE_CODE_LENGTH = 2;
const MAX_TOKEN_GENERATION_ATTEMPTS = 800;
type AnalyticsFilterArgs = {
jobId?: string | null;
from?: number | null;
to?: number | null;
includeBots?: boolean;
limit?: number;
};
export type TracerLinkStatsRow = {
tracerLinkId: string;
token: string;
sourcePath: string;
sourceLabel: string;
destinationUrl: string;
createdAt: string;
updatedAt: string;
isActive: boolean;
clicks: number;
uniqueOpens: number;
botClicks: number;
humanClicks: number;
lastClickedAt: number | null;
};
function normalizeLimit(
limit: number | null | undefined,
fallback = 20,
): number {
if (!Number.isFinite(limit)) return fallback;
return Math.max(1, Math.min(500, Math.floor(limit as number)));
}
function normalizeNumber(value: unknown): number {
if (typeof value === "number") return value;
if (typeof value === "bigint") return Number(value);
return Number(value ?? 0);
}
function normalizeSlugPrefix(value: string): string {
const cleaned = value
.trim()
.toLowerCase()
.replace(/[^a-z-]/g, "")
.replace(/-+/g, "-")
.replace(/^-+|-+$/g, "");
return cleaned.length > 0 ? cleaned : "candidate-company";
}
function randomTraceCode(): string {
const first =
TRACE_CODE_ALPHABET[Math.floor(Math.random() * TRACE_CODE_ALPHABET.length)];
const second =
TRACE_CODE_ALPHABET[Math.floor(Math.random() * TRACE_CODE_ALPHABET.length)];
return `${first}${second}`.slice(0, TRACE_CODE_LENGTH);
}
function isUniqueConstraintError(error: unknown): boolean {
if (!(error instanceof Error)) return false;
return error.message.toLowerCase().includes("unique constraint failed");
}
function buildEventFilters(args: AnalyticsFilterArgs) {
const filters = [];
if (typeof args.from === "number") {
filters.push(gte(tracerClickEvents.clickedAt, args.from));
}
if (typeof args.to === "number") {
filters.push(lte(tracerClickEvents.clickedAt, args.to));
}
if (typeof args.jobId === "string" && args.jobId.trim().length > 0) {
filters.push(eq(tracerLinks.jobId, args.jobId.trim()));
}
if (!args.includeBots) {
filters.push(eq(tracerClickEvents.isLikelyBot, false));
}
return filters;
}
export async function getOrCreateTracerLink(args: {
jobId: string;
sourcePath: string;
sourceLabel: string;
destinationUrl: string;
destinationUrlHash: string;
slugPrefix: string;
}): Promise<typeof tracerLinks.$inferSelect> {
const now = new Date().toISOString();
const slugPrefix = normalizeSlugPrefix(args.slugPrefix);
const [existing] = await db
.select()
.from(tracerLinks)
.where(
and(
eq(tracerLinks.jobId, args.jobId),
eq(tracerLinks.sourcePath, args.sourcePath),
eq(tracerLinks.destinationUrlHash, args.destinationUrlHash),
),
)
.limit(1);
if (existing) return existing;
const attemptedCodes = new Set<string>();
for (let attempt = 0; attempt < MAX_TOKEN_GENERATION_ATTEMPTS; attempt++) {
const suffix = randomTraceCode();
if (attemptedCodes.has(suffix)) {
continue;
}
attemptedCodes.add(suffix);
const token = `${slugPrefix}-${suffix}`;
let insertResult: { changes: number } | null = null;
try {
insertResult = await db
.insert(tracerLinks)
.values({
id: createId(),
token,
jobId: args.jobId,
sourcePath: args.sourcePath,
sourceLabel: args.sourceLabel,
destinationUrl: args.destinationUrl,
destinationUrlHash: args.destinationUrlHash,
isActive: true,
createdAt: now,
updatedAt: now,
})
.onConflictDoNothing({
target: [
tracerLinks.jobId,
tracerLinks.sourcePath,
tracerLinks.destinationUrlHash,
],
})
.run();
} catch (error) {
if (!isUniqueConstraintError(error)) {
throw error;
}
}
if (insertResult?.changes && insertResult.changes > 0) {
const [created] = await db
.select()
.from(tracerLinks)
.where(eq(tracerLinks.token, token))
.limit(1);
if (created) return created;
}
const [reused] = await db
.select()
.from(tracerLinks)
.where(
and(
eq(tracerLinks.jobId, args.jobId),
eq(tracerLinks.sourcePath, args.sourcePath),
eq(tracerLinks.destinationUrlHash, args.destinationUrlHash),
),
)
.limit(1);
if (reused) return reused;
}
throw new Error(
`Failed to create readable tracer link for prefix '${slugPrefix}' after retries`,
);
}
export async function findActiveTracerLinkByToken(token: string): Promise<{
id: string;
token: string;
jobId: string;
destinationUrl: string;
sourcePath: string;
sourceLabel: string;
} | null> {
const [row] = await db
.select({
id: tracerLinks.id,
token: tracerLinks.token,
jobId: tracerLinks.jobId,
destinationUrl: tracerLinks.destinationUrl,
sourcePath: tracerLinks.sourcePath,
sourceLabel: tracerLinks.sourceLabel,
})
.from(tracerLinks)
.where(and(eq(tracerLinks.token, token), eq(tracerLinks.isActive, true)))
.limit(1);
return row ?? null;
}
export async function insertTracerClickEvent(args: {
tracerLinkId: string;
clickedAt: number;
requestId: string | null;
isLikelyBot: boolean;
deviceType: string;
uaFamily: string;
osFamily: string;
referrerHost: string | null;
ipHash: string | null;
uniqueFingerprintHash: string | null;
}): Promise<void> {
await db.insert(tracerClickEvents).values({
id: createId(),
tracerLinkId: args.tracerLinkId,
clickedAt: args.clickedAt,
requestId: args.requestId,
isLikelyBot: args.isLikelyBot,
deviceType: args.deviceType,
uaFamily: args.uaFamily,
osFamily: args.osFamily,
referrerHost: args.referrerHost,
ipHash: args.ipHash,
uniqueFingerprintHash: args.uniqueFingerprintHash,
});
}
export async function listTracerLinkStatsByJob(
jobId: string,
args: Omit<AnalyticsFilterArgs, "jobId"> = {},
): Promise<TracerLinkStatsRow[]> {
const joinFilters = [];
if (typeof args.from === "number") {
joinFilters.push(gte(tracerClickEvents.clickedAt, args.from));
}
if (typeof args.to === "number") {
joinFilters.push(lte(tracerClickEvents.clickedAt, args.to));
}
if (!args.includeBots) {
joinFilters.push(eq(tracerClickEvents.isLikelyBot, false));
}
const rows = await db
.select({
tracerLinkId: tracerLinks.id,
token: tracerLinks.token,
sourcePath: tracerLinks.sourcePath,
sourceLabel: tracerLinks.sourceLabel,
destinationUrl: tracerLinks.destinationUrl,
createdAt: tracerLinks.createdAt,
updatedAt: tracerLinks.updatedAt,
isActive: tracerLinks.isActive,
clicks: sql<number>`count(${tracerClickEvents.id})`,
uniqueOpens: sql<number>`count(distinct ${tracerClickEvents.uniqueFingerprintHash})`,
botClicks: sql<number>`coalesce(sum(case when ${tracerClickEvents.isLikelyBot} = 1 then 1 else 0 end), 0)`,
lastClickedAt: sql<number | null>`max(${tracerClickEvents.clickedAt})`,
})
.from(tracerLinks)
.leftJoin(
tracerClickEvents,
and(eq(tracerLinks.id, tracerClickEvents.tracerLinkId), ...joinFilters),
)
.where(eq(tracerLinks.jobId, jobId))
.groupBy(tracerLinks.id)
.orderBy(
desc(sql`count(${tracerClickEvents.id})`),
desc(sql`max(${tracerClickEvents.clickedAt})`),
desc(tracerLinks.updatedAt),
);
return rows.map((row) => {
const clicks = normalizeNumber(row.clicks);
const botClicks = normalizeNumber(row.botClicks);
return {
tracerLinkId: row.tracerLinkId,
token: row.token,
sourcePath: row.sourcePath,
sourceLabel: row.sourceLabel,
destinationUrl: row.destinationUrl,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
isActive: Boolean(row.isActive),
clicks,
uniqueOpens: normalizeNumber(row.uniqueOpens),
botClicks,
humanClicks: Math.max(0, clicks - botClicks),
lastClickedAt:
row.lastClickedAt === null ? null : normalizeNumber(row.lastClickedAt),
};
});
}
export async function getTracerAnalyticsTotals(
args: AnalyticsFilterArgs,
): Promise<{
clicks: number;
uniqueOpens: number;
botClicks: number;
humanClicks: number;
}> {
const filters = buildEventFilters(args);
const [row] = await db
.select({
clicks: sql<number>`count(${tracerClickEvents.id})`,
uniqueOpens: sql<number>`count(distinct ${tracerClickEvents.uniqueFingerprintHash})`,
botClicks: sql<number>`coalesce(sum(case when ${tracerClickEvents.isLikelyBot} = 1 then 1 else 0 end), 0)`,
})
.from(tracerClickEvents)
.innerJoin(tracerLinks, eq(tracerClickEvents.tracerLinkId, tracerLinks.id))
.where(filters.length > 0 ? and(...filters) : undefined);
const clicks = normalizeNumber(row?.clicks);
const botClicks = normalizeNumber(row?.botClicks);
return {
clicks,
uniqueOpens: normalizeNumber(row?.uniqueOpens),
botClicks,
humanClicks: Math.max(0, clicks - botClicks),
};
}
export async function getTracerAnalyticsTimeSeries(
args: AnalyticsFilterArgs,
): Promise<
Array<{
day: string;
clicks: number;
uniqueOpens: number;
botClicks: number;
humanClicks: number;
}>
> {
const filters = buildEventFilters(args);
const rows = await db
.select({
day: sql<string>`date(${tracerClickEvents.clickedAt}, 'unixepoch')`,
clicks: sql<number>`count(${tracerClickEvents.id})`,
uniqueOpens: sql<number>`count(distinct ${tracerClickEvents.uniqueFingerprintHash})`,
botClicks: sql<number>`coalesce(sum(case when ${tracerClickEvents.isLikelyBot} = 1 then 1 else 0 end), 0)`,
})
.from(tracerClickEvents)
.innerJoin(tracerLinks, eq(tracerClickEvents.tracerLinkId, tracerLinks.id))
.where(filters.length > 0 ? and(...filters) : undefined)
.groupBy(sql`date(${tracerClickEvents.clickedAt}, 'unixepoch')`)
.orderBy(sql`date(${tracerClickEvents.clickedAt}, 'unixepoch') asc`);
return rows.map((row) => {
const clicks = normalizeNumber(row.clicks);
const botClicks = normalizeNumber(row.botClicks);
return {
day: row.day,
clicks,
uniqueOpens: normalizeNumber(row.uniqueOpens),
botClicks,
humanClicks: Math.max(0, clicks - botClicks),
};
});
}
export async function getTracerAnalyticsTopJobs(
args: AnalyticsFilterArgs,
): Promise<
Array<{
jobId: string;
title: string;
employer: string;
clicks: number;
uniqueOpens: number;
botClicks: number;
humanClicks: number;
lastClickedAt: number | null;
}>
> {
const filters = buildEventFilters(args);
const limit = normalizeLimit(args.limit, 20);
const rows = await db
.select({
jobId: jobs.id,
title: jobs.title,
employer: jobs.employer,
clicks: sql<number>`count(${tracerClickEvents.id})`,
uniqueOpens: sql<number>`count(distinct ${tracerClickEvents.uniqueFingerprintHash})`,
botClicks: sql<number>`coalesce(sum(case when ${tracerClickEvents.isLikelyBot} = 1 then 1 else 0 end), 0)`,
lastClickedAt: sql<number | null>`max(${tracerClickEvents.clickedAt})`,
})
.from(tracerClickEvents)
.innerJoin(tracerLinks, eq(tracerClickEvents.tracerLinkId, tracerLinks.id))
.innerJoin(jobs, eq(tracerLinks.jobId, jobs.id))
.where(filters.length > 0 ? and(...filters) : undefined)
.groupBy(jobs.id)
.orderBy(
desc(sql`count(${tracerClickEvents.id})`),
desc(sql`max(${tracerClickEvents.clickedAt})`),
)
.limit(limit);
return rows.map((row) => {
const clicks = normalizeNumber(row.clicks);
const botClicks = normalizeNumber(row.botClicks);
return {
jobId: row.jobId,
title: row.title,
employer: row.employer,
clicks,
uniqueOpens: normalizeNumber(row.uniqueOpens),
botClicks,
humanClicks: Math.max(0, clicks - botClicks),
lastClickedAt:
row.lastClickedAt === null ? null : normalizeNumber(row.lastClickedAt),
};
});
}
export async function getTracerAnalyticsTopLinks(
args: AnalyticsFilterArgs,
): Promise<
Array<{
tracerLinkId: string;
token: string;
jobId: string;
title: string;
employer: string;
sourcePath: string;
sourceLabel: string;
destinationUrl: string;
clicks: number;
uniqueOpens: number;
botClicks: number;
humanClicks: number;
lastClickedAt: number | null;
}>
> {
const filters = buildEventFilters(args);
const limit = normalizeLimit(args.limit, 20);
const rows = await db
.select({
tracerLinkId: tracerLinks.id,
token: tracerLinks.token,
jobId: jobs.id,
title: jobs.title,
employer: jobs.employer,
sourcePath: tracerLinks.sourcePath,
sourceLabel: tracerLinks.sourceLabel,
destinationUrl: tracerLinks.destinationUrl,
clicks: sql<number>`count(${tracerClickEvents.id})`,
uniqueOpens: sql<number>`count(distinct ${tracerClickEvents.uniqueFingerprintHash})`,
botClicks: sql<number>`coalesce(sum(case when ${tracerClickEvents.isLikelyBot} = 1 then 1 else 0 end), 0)`,
lastClickedAt: sql<number | null>`max(${tracerClickEvents.clickedAt})`,
})
.from(tracerClickEvents)
.innerJoin(tracerLinks, eq(tracerClickEvents.tracerLinkId, tracerLinks.id))
.innerJoin(jobs, eq(tracerLinks.jobId, jobs.id))
.where(filters.length > 0 ? and(...filters) : undefined)
.groupBy(tracerLinks.id)
.orderBy(
desc(sql`count(${tracerClickEvents.id})`),
desc(sql`max(${tracerClickEvents.clickedAt})`),
)
.limit(limit);
return rows.map((row) => {
const clicks = normalizeNumber(row.clicks);
const botClicks = normalizeNumber(row.botClicks);
return {
tracerLinkId: row.tracerLinkId,
token: row.token,
jobId: row.jobId,
title: row.title,
employer: row.employer,
sourcePath: row.sourcePath,
sourceLabel: row.sourceLabel,
destinationUrl: row.destinationUrl,
clicks,
uniqueOpens: normalizeNumber(row.uniqueOpens),
botClicks,
humanClicks: Math.max(0, clicks - botClicks),
lastClickedAt:
row.lastClickedAt === null ? null : normalizeNumber(row.lastClickedAt),
};
});
}

View File

@ -2,6 +2,8 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import { generatePdf } from "./pdf"; import { generatePdf } from "./pdf";
import { getProfile } from "./profile"; import { getProfile } from "./profile";
process.env.DATA_DIR = "/tmp";
// Define mock data in hoisted block // Define mock data in hoisted block
const { mocks, mockProfile, mockRxResumeClient } = vi.hoisted(() => { const { mocks, mockProfile, mockRxResumeClient } = vi.hoisted(() => {
const profile = { const profile = {
@ -85,6 +87,7 @@ vi.mock("node:fs/promises", async () => {
vi.mock("fs", () => ({ vi.mock("fs", () => ({
existsSync: vi.fn().mockReturnValue(true), existsSync: vi.fn().mockReturnValue(true),
mkdirSync: vi.fn(),
createWriteStream: vi.fn().mockReturnValue({ createWriteStream: vi.fn().mockReturnValue({
on: vi.fn(), on: vi.fn(),
write: vi.fn(), write: vi.fn(),
@ -92,6 +95,7 @@ vi.mock("fs", () => ({
}), }),
default: { default: {
existsSync: vi.fn().mockReturnValue(true), existsSync: vi.fn().mockReturnValue(true),
mkdirSync: vi.fn(),
createWriteStream: vi.fn().mockReturnValue({ createWriteStream: vi.fn().mockReturnValue({
on: vi.fn(), on: vi.fn(),
write: vi.fn(), write: vi.fn(),
@ -102,6 +106,7 @@ vi.mock("fs", () => ({
vi.mock("node:fs", () => ({ vi.mock("node:fs", () => ({
existsSync: vi.fn().mockReturnValue(true), existsSync: vi.fn().mockReturnValue(true),
mkdirSync: vi.fn(),
createWriteStream: vi.fn().mockReturnValue({ createWriteStream: vi.fn().mockReturnValue({
on: vi.fn(), on: vi.fn(),
write: vi.fn(), write: vi.fn(),
@ -109,6 +114,7 @@ vi.mock("node:fs", () => ({
}), }),
default: { default: {
existsSync: vi.fn().mockReturnValue(true), existsSync: vi.fn().mockReturnValue(true),
mkdirSync: vi.fn(),
createWriteStream: vi.fn().mockReturnValue({ createWriteStream: vi.fn().mockReturnValue({
on: vi.fn(), on: vi.fn(),
write: vi.fn(), write: vi.fn(),
@ -135,6 +141,13 @@ vi.mock("./projectSelection", () => ({
pickProjectIdsForJob: vi.fn().mockResolvedValue([]), pickProjectIdsForJob: vi.fn().mockResolvedValue([]),
})); }));
vi.mock("./tracer-links", () => ({
resolveTracerPublicBaseUrl: vi.fn().mockReturnValue("https://jobops.example"),
rewriteResumeLinksWithTracer: vi
.fn()
.mockResolvedValue({ rewrittenLinks: 0 }),
}));
vi.mock("./resumeProjects", () => ({ vi.mock("./resumeProjects", () => ({
extractProjectsFromProfile: vi extractProjectsFromProfile: vi
.fn() .fn()

View File

@ -147,6 +147,18 @@ vi.mock("./resumeProjects", () => ({
}), }),
})); }));
const mockTracerLinks = vi.hoisted(() => ({
resolveTracerPublicBaseUrl: vi.fn().mockReturnValue("https://jobops.example"),
rewriteResumeLinksWithTracer: vi
.fn()
.mockResolvedValue({ rewrittenLinks: 2 }),
}));
vi.mock("./tracer-links", () => ({
resolveTracerPublicBaseUrl: mockTracerLinks.resolveTracerPublicBaseUrl,
rewriteResumeLinksWithTracer: mockTracerLinks.rewriteResumeLinksWithTracer,
}));
// Mock the RxResumeClient // Mock the RxResumeClient
vi.mock("./rxresume-client", () => ({ vi.mock("./rxresume-client", () => ({
RxResumeClient: vi.fn().mockImplementation(function (this: any) { RxResumeClient: vi.fn().mockImplementation(function (this: any) {
@ -214,6 +226,12 @@ describe("PDF Service Tailoring Logic", () => {
vi.clearAllMocks(); vi.clearAllMocks();
mocks.readFile.mockResolvedValue(JSON.stringify(mockProfile)); mocks.readFile.mockResolvedValue(JSON.stringify(mockProfile));
mockRxResumeClient.clearLastCreateData(); mockRxResumeClient.clearLastCreateData();
mockTracerLinks.resolveTracerPublicBaseUrl.mockReturnValue(
"https://jobops.example",
);
mockTracerLinks.rewriteResumeLinksWithTracer.mockResolvedValue({
rewrittenLinks: 2,
});
}); });
it("should use provided selectedProjectIds and BYPASS AI selection", async () => { it("should use provided selectedProjectIds and BYPASS AI selection", async () => {
@ -281,4 +299,27 @@ describe("PDF Service Tailoring Logic", () => {
).length; ).length;
expect(visibleCount).toBe(1); expect(visibleCount).toBe(1);
}); });
it("does not rewrite links when tracer links are disabled", async () => {
await generatePdf("job-no-tracer", {}, "desc", undefined, undefined, {
tracerLinksEnabled: false,
});
expect(mockTracerLinks.resolveTracerPublicBaseUrl).not.toHaveBeenCalled();
expect(mockTracerLinks.rewriteResumeLinksWithTracer).not.toHaveBeenCalled();
});
it("rewrites links when tracer links are enabled", async () => {
await generatePdf("job-with-tracer", {}, "desc", undefined, undefined, {
tracerLinksEnabled: true,
requestOrigin: "https://jobops.example",
});
expect(mockTracerLinks.resolveTracerPublicBaseUrl).toHaveBeenCalledWith({
requestOrigin: "https://jobops.example",
});
expect(mockTracerLinks.rewriteResumeLinksWithTracer).toHaveBeenCalledTimes(
1,
);
});
}); });

View File

@ -17,6 +17,10 @@ import {
resolveResumeProjectsSettings, resolveResumeProjectsSettings,
} from "./resumeProjects"; } from "./resumeProjects";
import { RxResumeClient } from "./rxresume-client"; import { RxResumeClient } from "./rxresume-client";
import {
resolveTracerPublicBaseUrl,
rewriteResumeLinksWithTracer,
} from "./tracer-links";
const OUTPUT_DIR = join(getDataDir(), "pdfs"); const OUTPUT_DIR = join(getDataDir(), "pdfs");
@ -32,6 +36,12 @@ export interface TailoredPdfContent {
skills?: Array<{ name: string; keywords: string[] }> | null; skills?: Array<{ name: string; keywords: string[] }> | null;
} }
export interface GeneratePdfOptions {
tracerLinksEnabled?: boolean;
requestOrigin?: string | null;
tracerCompanyName?: string | null;
}
/** /**
* Get RxResume credentials from environment variables or database settings. * Get RxResume credentials from environment variables or database settings.
*/ */
@ -104,6 +114,7 @@ export async function generatePdf(
jobDescription: string, jobDescription: string,
_baseResumePath?: string, // Deprecated: now always uses getProfile() which fetches from v4 API _baseResumePath?: string, // Deprecated: now always uses getProfile() which fetches from v4 API
selectedProjectIds?: string | null, selectedProjectIds?: string | null,
options?: GeneratePdfOptions,
): Promise<PdfResult> { ): Promise<PdfResult> {
console.log(`📄 Generating PDF for job ${jobId} using RxResume v4 API...`); console.log(`📄 Generating PDF for job ${jobId} using RxResume v4 API...`);
@ -264,6 +275,24 @@ export async function generatePdf(
); );
} }
if (options?.tracerLinksEnabled) {
const tracerBaseUrl = resolveTracerPublicBaseUrl({
requestOrigin: options.requestOrigin,
});
if (!tracerBaseUrl) {
throw new Error(
"Tracer links are enabled but no public base URL is available. Set JOBOPS_PUBLIC_BASE_URL.",
);
}
await rewriteResumeLinksWithTracer({
jobId,
resumeData: baseResume,
publicBaseUrl: tracerBaseUrl,
companyName: options.tracerCompanyName ?? null,
});
}
// Use withAutoRefresh to handle token caching and 401 retry automatically // Use withAutoRefresh to handle token caching and 401 retry automatically
const outputPath = join(OUTPUT_DIR, `resume_${jobId}.pdf`); const outputPath = join(OUTPUT_DIR, `resume_${jobId}.pdf`);

View File

@ -0,0 +1,306 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import * as tracerLinksRepo from "../repositories/tracer-links";
import {
_resetTracerReadinessCacheForTests,
getTracerReadiness,
resolveTracerPublicBaseUrl,
resolveTracerRedirect,
rewriteResumeLinksWithTracer,
} from "./tracer-links";
vi.mock("../repositories/tracer-links", () => ({
getOrCreateTracerLink: vi.fn(),
findActiveTracerLinkByToken: vi.fn(),
insertTracerClickEvent: vi.fn(),
getTracerAnalyticsTotals: vi.fn(),
getTracerAnalyticsTimeSeries: vi.fn(),
getTracerAnalyticsTopJobs: vi.fn(),
getTracerAnalyticsTopLinks: vi.fn(),
listTracerLinkStatsByJob: vi.fn(),
}));
describe("tracer-links service", () => {
const originalEnv = process.env.JOBOPS_PUBLIC_BASE_URL;
beforeEach(() => {
vi.clearAllMocks();
_resetTracerReadinessCacheForTests();
vi.unstubAllGlobals();
delete process.env.JOBOPS_PUBLIC_BASE_URL;
});
afterEach(() => {
_resetTracerReadinessCacheForTests();
vi.unstubAllGlobals();
if (originalEnv === undefined) {
delete process.env.JOBOPS_PUBLIC_BASE_URL;
} else {
process.env.JOBOPS_PUBLIC_BASE_URL = originalEnv;
}
});
it("rewrites all eligible resume url fields", async () => {
const resumeData = {
basics: {
name: "Sarfaraz Khan",
url: {
label: "Portfolio",
href: "https://portfolio.example.com",
},
},
sections: {
projects: {
items: [
{
name: "P1",
url: {
label: "",
href: "https://projects.example.com/p1",
},
},
{
name: "P2",
url: {
label: "",
href: "mailto:hello@example.com",
},
},
],
},
profiles: {
items: [
{
network: "GitHub",
url: {
label: "GitHub",
href: "https://github.com/example",
},
},
],
},
},
};
vi.mocked(tracerLinksRepo.getOrCreateTracerLink)
.mockResolvedValueOnce({
id: "l1",
token: "tok-1",
} as any)
.mockResolvedValueOnce({
id: "l2",
token: "tok-2",
} as any)
.mockResolvedValueOnce({
id: "l3",
token: "tok-3",
} as any);
const result = await rewriteResumeLinksWithTracer({
jobId: "job-1",
resumeData,
publicBaseUrl: "https://jobops.example.com",
companyName: "Amazon",
});
expect(result.rewrittenLinks).toBe(3);
expect(resumeData.basics.url.href).toBe(
"https://jobops.example.com/cv/tok-1",
);
expect(resumeData.basics.url.label).toBe("Portfolio");
expect(resumeData.sections.projects.items[0].url.href).toBe(
"https://jobops.example.com/cv/tok-2",
);
expect(resumeData.sections.projects.items[0].url.label).toBe(
"https://jobops.example.com/cv/tok-2",
);
expect(resumeData.sections.profiles.items[0].url.href).toBe(
"https://jobops.example.com/cv/tok-3",
);
expect(resumeData.sections.profiles.items[0].url.label).toBe("GitHub");
// Non-http links are untouched.
expect(resumeData.sections.projects.items[1].url.href).toBe(
"mailto:hello@example.com",
);
expect(
vi.mocked(tracerLinksRepo.getOrCreateTracerLink),
).toHaveBeenCalledTimes(3);
expect(
vi.mocked(tracerLinksRepo.getOrCreateTracerLink),
).toHaveBeenCalledWith(
expect.objectContaining({
jobId: "job-1",
sourcePath: "basics.url.href",
slugPrefix: "amazon",
}),
);
expect(
vi.mocked(tracerLinksRepo.getOrCreateTracerLink),
).toHaveBeenCalledWith(
expect.objectContaining({
jobId: "job-1",
sourcePath: "sections.projects.items[0].url.href",
sourceLabel: "Project Link 1",
}),
);
});
it("resolves public base url from request origin first, then env fallback", () => {
process.env.JOBOPS_PUBLIC_BASE_URL = "https://fallback.example.com/";
expect(
resolveTracerPublicBaseUrl({
requestOrigin: "https://request.example.com/",
}),
).toBe("https://request.example.com");
expect(
resolveTracerPublicBaseUrl({
requestOrigin: null,
}),
).toBe("https://fallback.example.com");
});
it("records redirect click metadata without storing raw IP", async () => {
vi.mocked(tracerLinksRepo.findActiveTracerLinkByToken).mockResolvedValue({
id: "link-1",
token: "tok-abc",
jobId: "job-1",
destinationUrl: "https://github.com/example",
sourcePath: "sections.profiles.items[0].url.href",
sourceLabel: "GitHub",
});
const redirect = await resolveTracerRedirect({
token: "tok-abc",
requestId: "req-1",
ip: "203.0.113.42",
userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X) AppleWebKit",
referrer: "https://mail.example.com/thread/123",
});
expect(redirect).toEqual({
destinationUrl: "https://github.com/example",
jobId: "job-1",
});
expect(
vi.mocked(tracerLinksRepo.insertTracerClickEvent),
).toHaveBeenCalledTimes(1);
expect(
vi.mocked(tracerLinksRepo.insertTracerClickEvent),
).toHaveBeenCalledWith(
expect.objectContaining({
tracerLinkId: "link-1",
requestId: "req-1",
referrerHost: "mail.example.com",
ipHash: expect.any(String),
uniqueFingerprintHash: expect.any(String),
}),
);
});
it("reports unconfigured readiness when no public base URL is available", async () => {
const readiness = await getTracerReadiness({ requestOrigin: null });
expect(readiness.status).toBe("unconfigured");
expect(readiness.canEnable).toBe(false);
expect(readiness.publicBaseUrl).toBeNull();
expect(readiness.reason).toMatch(/no public jobops base url/i);
});
it("reports unavailable readiness for localhost/private origins", async () => {
const readiness = await getTracerReadiness({
requestOrigin: "http://localhost:3000",
});
expect(readiness.status).toBe("unavailable");
expect(readiness.canEnable).toBe(false);
expect(readiness.reason).toMatch(/internet-reachable/i);
});
it("reports ready readiness when health check succeeds", async () => {
process.env.JOBOPS_PUBLIC_BASE_URL = "https://my-jobops.example.com";
const realFetch = global.fetch;
const mockFetch = vi.fn(async (input: any, init?: RequestInit) => {
const url = typeof input === "string" ? input : input.toString();
if (url === "https://my-jobops.example.com/health") {
return new Response(JSON.stringify({ status: "ok" }), {
status: 200,
headers: {
"content-type": "application/json",
},
});
}
return realFetch(input, init);
});
vi.stubGlobal("fetch", mockFetch);
const readiness = await getTracerReadiness({
requestOrigin: null,
force: true,
});
expect(readiness.status).toBe("ready");
expect(readiness.canEnable).toBe(true);
expect(readiness.publicBaseUrl).toBe("https://my-jobops.example.com");
expect(mockFetch).toHaveBeenCalledWith(
"https://my-jobops.example.com/health",
expect.objectContaining({
method: "GET",
}),
);
});
it("classifies browser-like bot user agents as bot family", async () => {
vi.mocked(tracerLinksRepo.findActiveTracerLinkByToken).mockResolvedValue({
id: "link-2",
token: "tok-bot",
jobId: "job-1",
destinationUrl: "https://github.com/example",
sourcePath: "sections.profiles.items[0].url.href",
sourceLabel: "GitHub",
});
await resolveTracerRedirect({
token: "tok-bot",
requestId: "req-bot",
ip: "203.0.113.13",
userAgent: "Mozilla/5.0 Chrome/126.0.0.0 LinkedInBot",
referrer: null,
});
expect(
vi.mocked(tracerLinksRepo.insertTracerClickEvent),
).toHaveBeenCalledWith(
expect.objectContaining({
isLikelyBot: true,
uaFamily: "bot",
}),
);
});
it("fails closed when redirect destination is not http(s)", async () => {
vi.mocked(tracerLinksRepo.findActiveTracerLinkByToken).mockResolvedValue({
id: "link-3",
token: "tok-invalid",
jobId: "job-1",
destinationUrl: "javascript:alert(1)",
sourcePath: "basics.url.href",
sourceLabel: "Portfolio",
});
const redirect = await resolveTracerRedirect({
token: "tok-invalid",
requestId: "req-invalid",
ip: "203.0.113.25",
userAgent: "Mozilla/5.0",
referrer: null,
});
expect(redirect).toBeNull();
expect(
vi.mocked(tracerLinksRepo.insertTracerClickEvent),
).not.toHaveBeenCalled();
});
});

View File

@ -0,0 +1,622 @@
import { createHash } from "node:crypto";
import { logger } from "@infra/logger";
import type {
JobTracerLinksResponse,
TracerAnalyticsResponse,
TracerReadinessResponse,
} from "@shared/types";
import * as tracerLinksRepo from "../repositories/tracer-links";
type LinkNode = {
label?: unknown;
href?: unknown;
};
type LinkTarget = {
sourcePath: string;
sourceLabel: string;
destinationUrl: string;
applyTracerUrl: (url: string) => void;
};
const BOT_UA_PATTERN =
/\b(bot|crawler|spider|preview|scanner|security|headless|curl|wget|slackbot|discordbot|facebookexternalhit|whatsapp|skypeuripreview|linkedinbot|googleimageproxy)\b/i;
const TRACER_READINESS_TIMEOUT_MS = 5_000;
const TRACER_READINESS_CACHE_TTL_MS = 5 * 60_000;
type TracerReadinessCacheEntry = {
baseUrl: string | null;
checkedAt: number;
response: TracerReadinessResponse;
};
let tracerReadinessCache: TracerReadinessCacheEntry | null = null;
let tracerReadinessLastSuccessAt: number | null = null;
function hashText(value: string): string {
return createHash("sha256").update(value).digest("hex");
}
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
function isHttpUrl(value: string): boolean {
try {
const parsed = new URL(value);
return parsed.protocol === "http:" || parsed.protocol === "https:";
} catch {
return false;
}
}
function sanitizeLettersOnly(
value: string | null | undefined,
fallback: string,
maxLength: number,
): string {
if (!value) return fallback;
const normalized = value
.normalize("NFKD")
.replace(/[\u0300-\u036f]/g, "")
.toLowerCase()
.replace(/[^a-z]/g, "")
.slice(0, maxLength);
return normalized.length > 0 ? normalized : fallback;
}
function normalizeBaseUrl(value: string | null | undefined): string | null {
if (!value) return null;
const trimmed = value.trim();
if (!trimmed) return null;
if (!isHttpUrl(trimmed)) return null;
return trimmed.replace(/\/+$/, "");
}
function isLocalOrPrivateHostname(hostnameRaw: string): boolean {
const hostname = hostnameRaw.trim().toLowerCase();
if (!hostname) return true;
if (
hostname === "localhost" ||
hostname.endsWith(".localhost") ||
hostname.endsWith(".local")
) {
return true;
}
const ipv4Match = hostname.match(
/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/,
);
if (ipv4Match) {
const octets = ipv4Match.slice(1).map((part) => Number(part));
if (octets.some((octet) => Number.isNaN(octet) || octet > 255)) return true;
const [first, second] = octets;
if (
first === 10 ||
first === 127 ||
first === 0 ||
(first === 169 && second === 254) ||
(first === 172 && second >= 16 && second <= 31) ||
(first === 192 && second === 168)
) {
return true;
}
}
if (hostname.includes(":")) {
if (
hostname === "::1" ||
hostname.startsWith("fe80:") ||
hostname.startsWith("fc") ||
hostname.startsWith("fd")
) {
return true;
}
}
if (!hostname.includes(".") && !hostname.includes(":")) {
return true;
}
return false;
}
function resolveTracerReadinessBaseUrl(args: {
requestOrigin?: string | null;
}): string | null {
const fromEnv = normalizeBaseUrl(process.env.JOBOPS_PUBLIC_BASE_URL ?? null);
if (fromEnv) return fromEnv;
return normalizeBaseUrl(args.requestOrigin);
}
function makeTracerReadinessResponse(
status: TracerReadinessResponse["status"],
args: {
baseUrl: string | null;
checkedAt: number;
reason: string | null;
},
): TracerReadinessResponse {
return {
status,
canEnable: status === "ready",
publicBaseUrl: args.baseUrl,
healthUrl: args.baseUrl ? `${args.baseUrl}/health` : null,
checkedAt: args.checkedAt,
lastSuccessAt: tracerReadinessLastSuccessAt,
reason: args.reason,
};
}
async function fetchWithTimeout(
input: string,
timeoutMs: number,
): Promise<Response> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
return await fetch(input, {
method: "GET",
redirect: "manual",
signal: controller.signal,
headers: {
accept: "application/json,text/plain",
},
});
} finally {
clearTimeout(timer);
}
}
function deriveSourceLabel(sourcePath: string, linkNode: LinkNode): string {
const label = typeof linkNode.label === "string" ? linkNode.label.trim() : "";
if (label.length > 0) return label.slice(0, 200);
if (sourcePath === "basics.url.href") return "Portfolio";
const sectionMatch = sourcePath.match(
/^sections\.([a-z]+)\.items\[(\d+)\]\.url\.href$/,
);
if (sectionMatch) {
const section = sectionMatch[1];
const index = Number(sectionMatch[2]);
const nth = Number.isFinite(index) ? index + 1 : null;
const sectionLabels: Record<string, string> = {
profiles: "Profile",
projects: "Project",
experience: "Experience",
education: "Education",
awards: "Award",
certificates: "Certificate",
publications: "Publication",
volunteer: "Volunteer",
};
const baseLabel = sectionLabels[section] ?? "Resume";
return nth ? `${baseLabel} Link ${nth}` : `${baseLabel} Link`;
}
return "Resume Link";
}
function buildReadableSlugPrefix(companyName?: string | null): string {
const company = sanitizeLettersOnly(companyName, "company", 30);
return company;
}
function collectUrlTargets(
node: unknown,
path: string,
targets: LinkTarget[],
): void {
if (Array.isArray(node)) {
for (const [index, item] of node.entries()) {
const nextPath = `${path}[${index}]`;
collectUrlTargets(item, nextPath, targets);
}
return;
}
if (!isRecord(node)) return;
for (const [key, value] of Object.entries(node)) {
const nextPath = path.length > 0 ? `${path}.${key}` : key;
if (key === "url" && isRecord(value)) {
const linkNode = value as LinkNode;
const rawHref =
typeof linkNode.href === "string" ? linkNode.href.trim() : "";
if (rawHref && isHttpUrl(rawHref)) {
const sourcePath = `${nextPath}.href`;
targets.push({
sourcePath,
sourceLabel: deriveSourceLabel(sourcePath, linkNode),
destinationUrl: rawHref,
applyTracerUrl: (url: string) => {
const linkValue = value as { href?: unknown; label?: unknown };
const currentLabel =
typeof linkValue.label === "string" ? linkValue.label.trim() : "";
linkValue.href = url;
// Preserve descriptive labels; only rewrite label text when it was
// empty or mirrored the original destination URL.
if (!currentLabel || currentLabel === rawHref) {
linkValue.label = url;
}
},
});
}
continue;
}
collectUrlTargets(value, nextPath, targets);
}
}
function dayBucketFromUnixSeconds(unixSeconds: number): string {
return new Date(unixSeconds * 1000).toISOString().slice(0, 10);
}
function normalizeIpPrefix(ip: string | null): string | null {
if (!ip) return null;
const trimmed = ip.trim();
if (!trimmed) return null;
// Common Express format for IPv4-mapped IPv6
const clean = trimmed.startsWith("::ffff:") ? trimmed.slice(7) : trimmed;
if (/^\d+\.\d+\.\d+\.\d+$/.test(clean)) {
const parts = clean.split(".");
return `${parts[0]}.${parts[1]}.${parts[2]}.0/24`;
}
if (clean.includes(":")) {
const normalized = clean
.split(":")
.filter((part) => part.length > 0)
.slice(0, 4)
.join(":");
if (!normalized) return null;
return `${normalized}::/64`;
}
return null;
}
function getReferrerHost(referrer: string | null): string | null {
if (!referrer) return null;
try {
const host = new URL(referrer).host;
return host || null;
} catch {
return null;
}
}
function classifyDeviceType(userAgent: string): string {
const ua = userAgent.toLowerCase();
if (/(tablet|ipad)/.test(ua)) return "tablet";
if (/(mobile|iphone|android)/.test(ua)) return "mobile";
if (/(windows|macintosh|linux|x11|cros)/.test(ua)) return "desktop";
return "unknown";
}
function classifyUaFamily(userAgent: string): string {
const ua = userAgent.toLowerCase();
if (BOT_UA_PATTERN.test(ua)) return "bot";
if (ua.includes("edg/")) return "edge";
if (ua.includes("opr/") || ua.includes("opera")) return "opera";
if (ua.includes("chrome/")) return "chrome";
if (ua.includes("firefox/")) return "firefox";
if (ua.includes("safari/")) return "safari";
return "unknown";
}
function classifyOsFamily(userAgent: string): string {
const ua = userAgent.toLowerCase();
if (ua.includes("windows")) return "windows";
if (ua.includes("android")) return "android";
if (ua.includes("iphone") || ua.includes("ipad") || ua.includes("ios"))
return "ios";
if (ua.includes("mac os") || ua.includes("macintosh")) return "macos";
if (ua.includes("linux")) return "linux";
return "unknown";
}
function isLikelyBotUserAgent(userAgent: string): boolean {
return BOT_UA_PATTERN.test(userAgent);
}
export function resolveTracerPublicBaseUrl(args: {
requestOrigin?: string | null;
}): string | null {
const fromRequest = normalizeBaseUrl(args.requestOrigin);
if (fromRequest) return fromRequest;
return normalizeBaseUrl(process.env.JOBOPS_PUBLIC_BASE_URL ?? null);
}
export async function getTracerReadiness(
args: { requestOrigin?: string | null; force?: boolean } = {},
): Promise<TracerReadinessResponse> {
const baseUrl = resolveTracerReadinessBaseUrl({
requestOrigin: args.requestOrigin,
});
const checkedAt = Date.now();
const force = Boolean(args.force);
const cached = tracerReadinessCache;
if (
!force &&
cached &&
cached.baseUrl === baseUrl &&
checkedAt - cached.checkedAt < TRACER_READINESS_CACHE_TTL_MS
) {
return cached.response;
}
let response: TracerReadinessResponse;
if (!baseUrl) {
response = makeTracerReadinessResponse("unconfigured", {
baseUrl: null,
checkedAt,
reason:
"No public JobOps base URL is configured. Set JOBOPS_PUBLIC_BASE_URL.",
});
} else {
let hostname: string | null = null;
try {
hostname = new URL(baseUrl).hostname;
} catch {
hostname = null;
}
if (!hostname || isLocalOrPrivateHostname(hostname)) {
response = makeTracerReadinessResponse("unavailable", {
baseUrl,
checkedAt,
reason:
"Configured public URL must be internet-reachable (not localhost/private network).",
});
} else {
const healthUrl = `${baseUrl}/health`;
try {
const healthResponse = await fetchWithTimeout(
healthUrl,
TRACER_READINESS_TIMEOUT_MS,
);
if (!healthResponse.ok) {
response = makeTracerReadinessResponse("unavailable", {
baseUrl,
checkedAt,
reason: `Health check returned HTTP ${healthResponse.status}.`,
});
} else {
tracerReadinessLastSuccessAt = checkedAt;
response = makeTracerReadinessResponse("ready", {
baseUrl,
checkedAt,
reason: null,
});
}
} catch (error) {
const reason =
error instanceof Error && error.name === "AbortError"
? `Health check timed out after ${TRACER_READINESS_TIMEOUT_MS}ms.`
: error instanceof Error
? `Health check failed: ${error.message}.`
: "Health check failed.";
response = makeTracerReadinessResponse("unavailable", {
baseUrl,
checkedAt,
reason,
});
}
}
}
tracerReadinessCache = {
baseUrl,
checkedAt,
response,
};
if (response.status === "ready") {
logger.info("Tracer readiness check passed", {
route: "tracer-readiness",
publicBaseUrl: response.publicBaseUrl,
checkedAt: response.checkedAt,
});
} else {
logger.warn("Tracer readiness check failed", {
route: "tracer-readiness",
status: response.status,
publicBaseUrl: response.publicBaseUrl,
reason: response.reason,
checkedAt: response.checkedAt,
});
}
return response;
}
export function _resetTracerReadinessCacheForTests(): void {
tracerReadinessCache = null;
tracerReadinessLastSuccessAt = null;
}
export async function rewriteResumeLinksWithTracer(args: {
jobId: string;
resumeData: unknown;
publicBaseUrl: string;
companyName?: string | null;
}): Promise<{ rewrittenLinks: number }> {
const targets: LinkTarget[] = [];
collectUrlTargets(args.resumeData, "", targets);
const slugPrefix = buildReadableSlugPrefix(args.companyName);
for (const target of targets) {
const destinationUrlHash = hashText(target.destinationUrl);
const link = await tracerLinksRepo.getOrCreateTracerLink({
jobId: args.jobId,
sourcePath: target.sourcePath,
sourceLabel: target.sourceLabel,
destinationUrl: target.destinationUrl,
destinationUrlHash,
slugPrefix,
});
target.applyTracerUrl(`${args.publicBaseUrl}/cv/${link.token}`);
}
return { rewrittenLinks: targets.length };
}
export async function resolveTracerRedirect(args: {
token: string;
requestId: string | null;
ip: string | null;
userAgent: string | null;
referrer: string | null;
}): Promise<{ destinationUrl: string; jobId: string } | null> {
const link = await tracerLinksRepo.findActiveTracerLinkByToken(args.token);
if (!link) return null;
if (!isHttpUrl(link.destinationUrl)) {
logger.warn("Tracer link destination rejected: invalid scheme", {
route: "resolve-tracer-redirect",
token: args.token,
jobId: link.jobId,
});
return null;
}
const clickedAt = Math.floor(Date.now() / 1000);
const dayBucket = dayBucketFromUnixSeconds(clickedAt);
const userAgent = args.userAgent?.trim() ?? "";
const ipPrefix = normalizeIpPrefix(args.ip);
const ipHash = ipPrefix ? hashText(ipPrefix) : null;
const uniqueFingerprintSource = `${ipPrefix ?? "na"}|${userAgent.toLowerCase() || "na"}|${dayBucket}`;
const uniqueFingerprintHash =
ipPrefix || userAgent ? hashText(uniqueFingerprintSource) : null;
const isLikelyBot = isLikelyBotUserAgent(userAgent);
await tracerLinksRepo.insertTracerClickEvent({
tracerLinkId: link.id,
clickedAt,
requestId: args.requestId,
isLikelyBot,
deviceType: classifyDeviceType(userAgent),
uaFamily: classifyUaFamily(userAgent),
osFamily: classifyOsFamily(userAgent),
referrerHost: getReferrerHost(args.referrer),
ipHash,
uniqueFingerprintHash,
});
return {
destinationUrl: link.destinationUrl,
jobId: link.jobId,
};
}
export async function getTracerAnalytics(args: {
jobId?: string | null;
from?: number | null;
to?: number | null;
includeBots?: boolean;
limit?: number;
}): Promise<TracerAnalyticsResponse> {
const includeBots = Boolean(args.includeBots);
const limit = Number.isFinite(args.limit)
? Math.max(1, args.limit ?? 20)
: 20;
const [totals, timeSeries, topJobs, topLinks] = await Promise.all([
tracerLinksRepo.getTracerAnalyticsTotals({
...args,
includeBots,
limit,
}),
tracerLinksRepo.getTracerAnalyticsTimeSeries({
...args,
includeBots,
limit,
}),
tracerLinksRepo.getTracerAnalyticsTopJobs({
...args,
includeBots,
limit,
}),
tracerLinksRepo.getTracerAnalyticsTopLinks({
...args,
includeBots,
limit,
}),
]);
return {
filters: {
jobId: args.jobId ?? null,
from: args.from ?? null,
to: args.to ?? null,
includeBots,
limit,
},
totals,
timeSeries,
topJobs,
topLinks,
};
}
export async function getJobTracerLinksAnalytics(args: {
jobId: string;
from?: number | null;
to?: number | null;
includeBots?: boolean;
title: string;
employer: string;
tracerLinksEnabled: boolean;
}): Promise<JobTracerLinksResponse> {
const includeBots = Boolean(args.includeBots);
const links = await tracerLinksRepo.listTracerLinkStatsByJob(args.jobId, {
from: args.from,
to: args.to,
includeBots,
});
const totals = links.reduce(
(acc, item) => {
acc.links += 1;
acc.clicks += item.clicks;
acc.uniqueOpens += item.uniqueOpens;
acc.botClicks += item.botClicks;
acc.humanClicks += item.humanClicks;
return acc;
},
{
links: 0,
clicks: 0,
uniqueOpens: 0,
botClicks: 0,
humanClicks: 0,
},
);
return {
job: {
id: args.jobId,
title: args.title,
employer: args.employer,
tracerLinksEnabled: args.tracerLinksEnabled,
},
totals,
links,
};
}

View File

@ -56,6 +56,10 @@ describe("Tailoring Flow", () => {
"Senior TypeScript Developer", // Original JD "Senior TypeScript Developer", // Original JD
undefined, // Deprecated profile path undefined, // Deprecated profile path
"project-a,project-c", // The manually selected projects "project-a,project-c", // The manually selected projects
expect.objectContaining({
requestOrigin: null,
tracerLinksEnabled: undefined,
}),
); );
}); });
@ -86,6 +90,10 @@ describe("Tailoring Flow", () => {
"Junior Java Developer", "Junior Java Developer",
undefined, // Deprecated profile path undefined, // Deprecated profile path
undefined, // No projects selected undefined, // No projects selected
expect.objectContaining({
requestOrigin: null,
tracerLinksEnabled: undefined,
}),
); );
}); });
}); });

View File

@ -35,6 +35,7 @@ export const createJob = (overrides: Partial<Job> = {}): Job => ({
tailoredSkills: null, tailoredSkills: null,
selectedProjectIds: null, selectedProjectIds: null,
pdfPath: null, pdfPath: null,
tracerLinksEnabled: false,
sponsorMatchScore: null, sponsorMatchScore: null,
sponsorMatchNames: null, sponsorMatchNames: null,
jobType: null, jobType: null,

View File

@ -162,6 +162,7 @@ export interface Job {
tailoredSkills: string | null; // Generated resume skills (JSON) tailoredSkills: string | null; // Generated resume skills (JSON)
selectedProjectIds: string | null; // Comma-separated IDs of selected projects selectedProjectIds: string | null; // Comma-separated IDs of selected projects
pdfPath: string | null; // Path to generated PDF pdfPath: string | null; // Path to generated PDF
tracerLinksEnabled: boolean; // Rewrite outbound resume links to tracer links on next PDF generation
sponsorMatchScore: number | null; // 0-100 fuzzy match score with visa sponsors sponsorMatchScore: number | null; // 0-100 fuzzy match score with visa sponsors
sponsorMatchNames: string | null; // JSON array of matched sponsor names (when 100% matches or top match) sponsorMatchNames: string | null; // JSON array of matched sponsor names (when 100% matches or top match)
@ -317,6 +318,7 @@ export interface UpdateJobInput {
tailoredSkills?: string; tailoredSkills?: string;
selectedProjectIds?: string; selectedProjectIds?: string;
pdfPath?: string; pdfPath?: string;
tracerLinksEnabled?: boolean;
appliedAt?: string; appliedAt?: string;
sponsorMatchScore?: number; sponsorMatchScore?: number;
sponsorMatchNames?: string; sponsorMatchNames?: string;
@ -368,6 +370,105 @@ export type ApiResponse<T> =
meta: ApiMeta; meta: ApiMeta;
}; };
export interface TracerAnalyticsTimeseriesPoint {
day: string; // YYYY-MM-DD
clicks: number;
uniqueOpens: number;
botClicks: number;
humanClicks: number;
}
export interface TracerAnalyticsTopJob {
jobId: string;
title: string;
employer: string;
clicks: number;
uniqueOpens: number;
botClicks: number;
humanClicks: number;
lastClickedAt: number | null;
}
export interface TracerAnalyticsTopLink {
tracerLinkId: string;
token: string;
jobId: string;
title: string;
employer: string;
sourcePath: string;
sourceLabel: string;
destinationUrl: string;
clicks: number;
uniqueOpens: number;
botClicks: number;
humanClicks: number;
lastClickedAt: number | null;
}
export interface TracerAnalyticsResponse {
filters: {
jobId: string | null;
from: number | null;
to: number | null;
includeBots: boolean;
limit: number;
};
totals: {
clicks: number;
uniqueOpens: number;
botClicks: number;
humanClicks: number;
};
timeSeries: TracerAnalyticsTimeseriesPoint[];
topJobs: TracerAnalyticsTopJob[];
topLinks: TracerAnalyticsTopLink[];
}
export interface JobTracerLinkAnalyticsItem {
tracerLinkId: string;
token: string;
sourcePath: string;
sourceLabel: string;
destinationUrl: string;
isActive: boolean;
createdAt: string;
updatedAt: string;
clicks: number;
uniqueOpens: number;
botClicks: number;
humanClicks: number;
lastClickedAt: number | null;
}
export interface JobTracerLinksResponse {
job: {
id: string;
title: string;
employer: string;
tracerLinksEnabled: boolean;
};
totals: {
links: number;
clicks: number;
uniqueOpens: number;
botClicks: number;
humanClicks: number;
};
links: JobTracerLinkAnalyticsItem[];
}
export type TracerReadinessStatus = "ready" | "unconfigured" | "unavailable";
export interface TracerReadinessResponse {
status: TracerReadinessStatus;
canEnable: boolean;
publicBaseUrl: string | null;
healthUrl: string | null;
checkedAt: number;
lastSuccessAt: number | null;
reason: string | null;
}
export const POST_APPLICATION_PROVIDERS = ["gmail", "imap"] as const; export const POST_APPLICATION_PROVIDERS = ["gmail", "imap"] as const;
export type PostApplicationProvider = export type PostApplicationProvider =
(typeof POST_APPLICATION_PROVIDERS)[number]; (typeof POST_APPLICATION_PROVIDERS)[number];