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:
parent
1146d065f0
commit
5ed74bb59c
@ -19,6 +19,11 @@ RXRESUME_PASSWORD=your_password_here
|
||||
BASIC_AUTH_USER=
|
||||
BASIC_AUTH_PASSWORD=
|
||||
|
||||
# Public base URL used to generate tracer links when PDFs are created by
|
||||
# background/pipeline runs (where request host cannot be inferred).
|
||||
# Example: JOBOPS_PUBLIC_BASE_URL=https://jobops.example.com
|
||||
JOBOPS_PUBLIC_BASE_URL=
|
||||
|
||||
# =============================================================================
|
||||
# Gmail OAuth (Tracking Inbox) - optional
|
||||
# =============================================================================
|
||||
|
||||
@ -123,9 +123,27 @@ High-level flow:
|
||||
1. Load selected base resume from RxResume.
|
||||
2. Apply tailored summary/headline/skills.
|
||||
3. Compute final visible projects from your selection rules.
|
||||
4. Create temporary resume in RxResume.
|
||||
5. Export PDF.
|
||||
6. Delete temporary resume.
|
||||
4. Optionally rewrite outbound links to tracer links (per-job toggle).
|
||||
5. Create temporary resume in RxResume.
|
||||
6. Export PDF.
|
||||
7. Delete temporary resume.
|
||||
|
||||
### Per-job tracer links
|
||||
|
||||
Before generating a PDF, each job can enable/disable tracer links.
|
||||
|
||||
- Disabled: original RxResume links remain unchanged.
|
||||
- Enabled: eligible outbound links are rewritten to `https://<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
|
||||
|
||||
|
||||
@ -18,6 +18,7 @@ It lets you configure:
|
||||
- Display and Ghostwriter defaults
|
||||
- Service credentials and basic auth
|
||||
- Reactive Resume project selection
|
||||
- Tracer Links readiness verification
|
||||
- Backup and scoring rules
|
||||
- Data-clearing actions in the Danger Zone
|
||||
|
||||
@ -81,6 +82,19 @@ Settings gives you runtime overrides for the key parts of discovery, scoring, ta
|
||||
- Must-include projects
|
||||
- AI-selectable projects
|
||||
|
||||
### Tracer Links
|
||||
|
||||
- Verify tracer readiness before enabling per-job tracing
|
||||
- Shows current status (`Ready`, `Unavailable`, `Unconfigured`, or stale state)
|
||||
- Displays the effective public base URL and last check time
|
||||
- Provides **Verify now** for an on-demand health check
|
||||
|
||||
Readiness requires:
|
||||
|
||||
- a valid public JobOps base URL
|
||||
- successful reachability of `<public-base-url>/health`
|
||||
- non-localhost/non-private host setup for public redirect usage
|
||||
|
||||
### Environment & Accounts
|
||||
|
||||
- Configure service accounts:
|
||||
@ -163,6 +177,12 @@ curl -X POST "http://localhost:3001/api/backups"
|
||||
- Verify URL reachability from the server host.
|
||||
- Confirm auth expectations on the receiver side (including secret/bearer token).
|
||||
|
||||
### Tracer links cannot be enabled
|
||||
|
||||
- Open **Settings → Tracer Links** and click **Verify now**.
|
||||
- Ensure `JOBOPS_PUBLIC_BASE_URL` is set for background/pipeline usage.
|
||||
- Ensure the configured host is publicly reachable and `/health` responds.
|
||||
|
||||
## Related pages
|
||||
|
||||
- [Reactive Resume](./reactive-resume)
|
||||
|
||||
151
docs-site/docs/features/tracer-links.md
Normal file
151
docs-site/docs/features/tracer-links.md
Normal 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)
|
||||
@ -57,8 +57,9 @@ These jobs already have tailored PDFs generated for the specific job description
|
||||
At this stage:
|
||||
|
||||
1. Open job details.
|
||||
2. Download the tailored PDF.
|
||||
3. Submit your application externally.
|
||||
2. Optionally enable tracer links for that specific job.
|
||||
3. Download the tailored PDF.
|
||||
4. Submit your application externally.
|
||||
|
||||
### 5) Mark jobs as applied in JobOps
|
||||
|
||||
@ -85,6 +86,8 @@ Once a job is marked `applied`, it becomes part of:
|
||||
- Increase tailored-job count only after score thresholds feel calibrated.
|
||||
- Expect scraper runtime variance by source.
|
||||
- Keep resume/project context up to date so scoring/tailoring quality stays high.
|
||||
- Use per-job tracer links when you want measurable outbound-link analytics.
|
||||
- If you use tracer links, review the risk note in [Tracer Links](../features/tracer-links): some recipients/security tools may treat redirects as suspicious.
|
||||
|
||||
## Related pages
|
||||
|
||||
|
||||
@ -60,17 +60,12 @@
|
||||
{
|
||||
"type": "category",
|
||||
"label": "Troubleshooting",
|
||||
"items": [
|
||||
"troubleshooting/common-problems"
|
||||
]
|
||||
"items": ["troubleshooting/common-problems"]
|
||||
},
|
||||
{
|
||||
"type": "category",
|
||||
"label": "Reference / FAQ",
|
||||
"items": [
|
||||
"reference/faq",
|
||||
"reference/documentation-style-guide"
|
||||
]
|
||||
"items": ["reference/faq", "reference/documentation-style-guide"]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@ -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"]
|
||||
|
||||
@ -17,6 +17,7 @@ import { InProgressBoardPage } from "./pages/InProgressBoardPage";
|
||||
import { JobPage } from "./pages/JobPage";
|
||||
import { OrchestratorPage } from "./pages/OrchestratorPage";
|
||||
import { SettingsPage } from "./pages/SettingsPage";
|
||||
import { TracerLinksPage } from "./pages/TracerLinksPage";
|
||||
import { TrackingInboxPage } from "./pages/TrackingInboxPage";
|
||||
import { VisaSponsorsPage } from "./pages/VisaSponsorsPage";
|
||||
|
||||
@ -106,6 +107,7 @@ export const App: React.FC = () => {
|
||||
element={<InProgressBoardPage />}
|
||||
/>
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
<Route path="/tracer-links" element={<TracerLinksPage />} />
|
||||
<Route path="/visa-sponsors" element={<VisaSponsorsPage />} />
|
||||
<Route path="/tracking-inbox" element={<TrackingInboxPage />} />
|
||||
<Route path="/jobs/:tab" element={<OrchestratorPage />} />
|
||||
|
||||
@ -24,6 +24,7 @@ import type {
|
||||
JobSource,
|
||||
JobsListResponse,
|
||||
JobsRevisionResponse,
|
||||
JobTracerLinksResponse,
|
||||
ManualJobDraft,
|
||||
ManualJobFetchResponse,
|
||||
ManualJobInferenceResponse,
|
||||
@ -39,6 +40,8 @@ import type {
|
||||
StageEvent,
|
||||
StageEventMetadata,
|
||||
StageTransitionTarget,
|
||||
TracerAnalyticsResponse,
|
||||
TracerReadinessResponse,
|
||||
ValidationResult,
|
||||
VisaSponsor,
|
||||
VisaSponsorSearchResponse,
|
||||
@ -399,6 +402,69 @@ export async function updateJob(
|
||||
});
|
||||
}
|
||||
|
||||
export async function getTracerAnalytics(options?: {
|
||||
jobId?: string;
|
||||
from?: number;
|
||||
to?: number;
|
||||
includeBots?: boolean;
|
||||
limit?: number;
|
||||
}): Promise<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>(
|
||||
endpoint: string,
|
||||
input: StreamSseInput,
|
||||
|
||||
@ -4,6 +4,7 @@ import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import type React from "react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import * as api from "../api";
|
||||
import { _resetTracerReadinessCache } from "../hooks/useTracerReadiness";
|
||||
import { JobDetailsEditDrawer } from "./JobDetailsEditDrawer";
|
||||
|
||||
vi.mock("@/components/ui/sheet", () => ({
|
||||
@ -27,6 +28,7 @@ vi.mock("../api", () => ({
|
||||
updateJob: vi.fn(),
|
||||
checkSponsor: vi.fn(),
|
||||
rescoreJob: vi.fn(),
|
||||
getTracerReadiness: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("sonner", () => ({
|
||||
@ -39,6 +41,16 @@ vi.mock("sonner", () => ({
|
||||
describe("JobDetailsEditDrawer", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
_resetTracerReadinessCache();
|
||||
vi.mocked(api.getTracerReadiness).mockResolvedValue({
|
||||
status: "ready",
|
||||
canEnable: true,
|
||||
publicBaseUrl: "https://my-jobops.example.com",
|
||||
healthUrl: "https://my-jobops.example.com/health",
|
||||
checkedAt: Date.now(),
|
||||
lastSuccessAt: Date.now(),
|
||||
reason: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("saves details and reruns sponsor check when employer changes", async () => {
|
||||
@ -138,4 +150,32 @@ describe("JobDetailsEditDrawer", () => {
|
||||
await waitFor(() => expect(api.rescoreJob).toHaveBeenCalledWith("job-1"));
|
||||
expect(onJobUpdated).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("persists tracer-links toggle with job updates", async () => {
|
||||
const onJobUpdated = vi.fn().mockResolvedValue(undefined);
|
||||
const onOpenChange = vi.fn();
|
||||
vi.mocked(api.updateJob).mockResolvedValue({} as Job);
|
||||
|
||||
render(
|
||||
<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,
|
||||
}),
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@ -4,6 +4,7 @@ import type React from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Sheet,
|
||||
@ -14,6 +15,7 @@ import {
|
||||
} from "@/components/ui/sheet";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import * as api from "../api";
|
||||
import { useTracerReadiness } from "../hooks/useTracerReadiness";
|
||||
|
||||
interface JobDetailsEditDrawerProps {
|
||||
open: boolean;
|
||||
@ -31,6 +33,7 @@ type JobDetailsDraft = {
|
||||
salary: string;
|
||||
deadline: string;
|
||||
jobDescription: string;
|
||||
tracerLinksEnabled: boolean;
|
||||
};
|
||||
|
||||
const emptyDraft: JobDetailsDraft = {
|
||||
@ -42,6 +45,7 @@ const emptyDraft: JobDetailsDraft = {
|
||||
salary: "",
|
||||
deadline: "",
|
||||
jobDescription: "",
|
||||
tracerLinksEnabled: false,
|
||||
};
|
||||
|
||||
const normalizeOptional = (value: string): string | null => {
|
||||
@ -60,6 +64,7 @@ const normalizeFromJob = (job: Job | null): JobDetailsDraft => {
|
||||
salary: job.salary ?? "",
|
||||
deadline: job.deadline ?? "",
|
||||
jobDescription: job.jobDescription ?? "",
|
||||
tracerLinksEnabled: Boolean(job.tracerLinksEnabled),
|
||||
};
|
||||
};
|
||||
|
||||
@ -81,6 +86,8 @@ export const JobDetailsEditDrawer: React.FC<JobDetailsEditDrawerProps> = ({
|
||||
const [draft, setDraft] = useState<JobDetailsDraft>(emptyDraft);
|
||||
const [validationError, setValidationError] = useState<string | null>(null);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const { readiness: tracerReadiness, isChecking: isTracerReadinessChecking } =
|
||||
useTracerReadiness();
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
@ -90,6 +97,14 @@ export const JobDetailsEditDrawer: React.FC<JobDetailsEditDrawerProps> = ({
|
||||
}, [job, open]);
|
||||
|
||||
const hasJob = !!job;
|
||||
const tracerCanEnable = Boolean(tracerReadiness?.canEnable);
|
||||
const tracerEnableBlocked = !draft.tracerLinksEnabled && !tracerCanEnable;
|
||||
const tracerEnableBlockedReason =
|
||||
tracerReadiness?.canEnable === false
|
||||
? (tracerReadiness.reason ??
|
||||
"Tracer links are unavailable right now. Verify Tracer Links in Settings.")
|
||||
: null;
|
||||
|
||||
const isDirty = useMemo(() => {
|
||||
if (!job) return false;
|
||||
const current = normalizeFromJob(job);
|
||||
@ -101,7 +116,8 @@ export const JobDetailsEditDrawer: React.FC<JobDetailsEditDrawerProps> = ({
|
||||
draft.location !== current.location ||
|
||||
draft.salary !== current.salary ||
|
||||
draft.deadline !== current.deadline ||
|
||||
draft.jobDescription !== current.jobDescription
|
||||
draft.jobDescription !== current.jobDescription ||
|
||||
draft.tracerLinksEnabled !== current.tracerLinksEnabled
|
||||
);
|
||||
}, [draft, job]);
|
||||
|
||||
@ -133,6 +149,17 @@ export const JobDetailsEditDrawer: React.FC<JobDetailsEditDrawerProps> = ({
|
||||
setValidationError("Application URL must be a valid URL.");
|
||||
return;
|
||||
}
|
||||
if (
|
||||
draft.tracerLinksEnabled &&
|
||||
!job.tracerLinksEnabled &&
|
||||
!tracerCanEnable
|
||||
) {
|
||||
setValidationError(
|
||||
tracerEnableBlockedReason ??
|
||||
"Tracer links are unavailable right now. Verify Tracer Links in Settings.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setValidationError(null);
|
||||
@ -150,6 +177,7 @@ export const JobDetailsEditDrawer: React.FC<JobDetailsEditDrawerProps> = ({
|
||||
salary: normalizeOptional(draft.salary),
|
||||
deadline: normalizeOptional(draft.deadline),
|
||||
jobDescription: normalizeOptional(draft.jobDescription),
|
||||
tracerLinksEnabled: draft.tracerLinksEnabled,
|
||||
});
|
||||
|
||||
if (employerChanged) {
|
||||
@ -281,6 +309,42 @@ export const JobDetailsEditDrawer: React.FC<JobDetailsEditDrawerProps> = ({
|
||||
/>
|
||||
</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">
|
||||
<label
|
||||
htmlFor="edit-job-description"
|
||||
|
||||
@ -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 }) => {
|
||||
if (score == null) {
|
||||
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 gap-4">
|
||||
<StatusPill status={job.status} />
|
||||
<TracerPill enabled={job.tracerLinksEnabled} />
|
||||
{showSponsorInfo && (
|
||||
<SponsorPill
|
||||
score={job.sponsorMatchScore}
|
||||
|
||||
@ -175,7 +175,9 @@ describe("OnboardingGate", () => {
|
||||
|
||||
await waitFor(() => expect(api.validateRxresume).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();
|
||||
});
|
||||
});
|
||||
|
||||
@ -3,6 +3,7 @@ import type { Job } from "@shared/types.js";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import * as api from "../api";
|
||||
import { _resetTracerReadinessCache } from "../hooks/useTracerReadiness";
|
||||
import { TailoringEditor } from "./TailoringEditor";
|
||||
|
||||
vi.mock("../api", () => ({
|
||||
@ -10,6 +11,7 @@ vi.mock("../api", () => ({
|
||||
updateJob: vi.fn().mockResolvedValue({}),
|
||||
summarizeJob: vi.fn(),
|
||||
generateJobPdf: vi.fn(),
|
||||
getTracerReadiness: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("sonner", () => ({
|
||||
@ -42,6 +44,16 @@ const ensureAccordionOpen = (name: string) => {
|
||||
describe("TailoringEditor", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
_resetTracerReadinessCache();
|
||||
vi.mocked(api.getTracerReadiness).mockResolvedValue({
|
||||
status: "ready",
|
||||
canEnable: true,
|
||||
publicBaseUrl: "https://my-jobops.example.com",
|
||||
healthUrl: "https://my-jobops.example.com/health",
|
||||
checkedAt: Date.now(),
|
||||
lastSuccessAt: Date.now(),
|
||||
reason: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("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("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,
|
||||
}),
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@ -3,12 +3,14 @@ import type { Job } from "@shared/types.js";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import * as api from "../../api";
|
||||
import { _resetTracerReadinessCache } from "../../hooks/useTracerReadiness";
|
||||
import { TailorMode } from "./TailorMode";
|
||||
|
||||
vi.mock("../../api", () => ({
|
||||
getResumeProjectsCatalog: vi.fn().mockResolvedValue([]),
|
||||
updateJob: vi.fn(),
|
||||
summarizeJob: vi.fn(),
|
||||
getTracerReadiness: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("sonner", () => ({
|
||||
@ -41,6 +43,16 @@ const ensureAccordionOpen = (name: string) => {
|
||||
describe("TailorMode", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
_resetTracerReadinessCache();
|
||||
vi.mocked(api.getTracerReadiness).mockResolvedValue({
|
||||
status: "ready",
|
||||
canEnable: true,
|
||||
publicBaseUrl: "https://my-jobops.example.com",
|
||||
healthUrl: "https://my-jobops.example.com/health",
|
||||
checkedAt: Date.now(),
|
||||
lastSuccessAt: Date.now(),
|
||||
reason: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not rehydrate local edits from same-job prop updates", async () => {
|
||||
|
||||
@ -3,6 +3,7 @@ import {
|
||||
Home,
|
||||
Inbox,
|
||||
LayoutDashboard,
|
||||
Link2,
|
||||
Settings,
|
||||
Shield,
|
||||
} from "lucide-react";
|
||||
@ -34,6 +35,12 @@ export const NAV_LINKS: NavLink[] = [
|
||||
activePaths: ["/applications/in-progress"],
|
||||
},
|
||||
{ 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: "/settings", label: "Settings", icon: Settings },
|
||||
];
|
||||
|
||||
@ -8,6 +8,7 @@ import {
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { ProjectSelector } from "../discovered-panel/ProjectSelector";
|
||||
import type { EditableSkillGroup } from "../tailoring-utils";
|
||||
|
||||
@ -18,6 +19,10 @@ interface TailoringSectionsProps {
|
||||
jobDescription: string;
|
||||
skillsDraft: EditableSkillGroup[];
|
||||
selectedIds: Set<string>;
|
||||
tracerLinksEnabled: boolean;
|
||||
tracerEnableBlocked: boolean;
|
||||
tracerEnableBlockedReason: string | null;
|
||||
tracerReadinessChecking?: boolean;
|
||||
openSkillGroupId: string;
|
||||
disableInputs: boolean;
|
||||
onSummaryChange: (value: string) => void;
|
||||
@ -32,6 +37,7 @@ interface TailoringSectionsProps {
|
||||
) => void;
|
||||
onRemoveSkillGroup: (id: string) => void;
|
||||
onToggleProject: (id: string) => void;
|
||||
onTracerLinksEnabledChange: (value: boolean) => void;
|
||||
}
|
||||
|
||||
const sectionClass = "rounded-lg border border-border/60 bg-muted/20 px-0";
|
||||
@ -47,6 +53,10 @@ export const TailoringSections: React.FC<TailoringSectionsProps> = ({
|
||||
jobDescription,
|
||||
skillsDraft,
|
||||
selectedIds,
|
||||
tracerLinksEnabled,
|
||||
tracerEnableBlocked,
|
||||
tracerEnableBlockedReason,
|
||||
tracerReadinessChecking = false,
|
||||
openSkillGroupId,
|
||||
disableInputs,
|
||||
onSummaryChange,
|
||||
@ -57,7 +67,11 @@ export const TailoringSections: React.FC<TailoringSectionsProps> = ({
|
||||
onUpdateSkillGroup,
|
||||
onRemoveSkillGroup,
|
||||
onToggleProject,
|
||||
onTracerLinksEnabledChange,
|
||||
}) => {
|
||||
const tracerToggleDisabled =
|
||||
disableInputs || (!tracerLinksEnabled && tracerEnableBlocked);
|
||||
|
||||
return (
|
||||
<Accordion type="multiple" className="space-y-3">
|
||||
<AccordionItem value="job-description" className={sectionClass}>
|
||||
@ -239,6 +253,42 @@ export const TailoringSections: React.FC<TailoringSectionsProps> = ({
|
||||
/>
|
||||
</AccordionContent>
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -6,6 +6,7 @@ import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import * as api from "../../api";
|
||||
import { useTracerReadiness } from "../../hooks/useTracerReadiness";
|
||||
import { TailoringSections } from "./TailoringSections";
|
||||
import { useTailoringDraft } from "./useTailoringDraft";
|
||||
|
||||
@ -49,6 +50,8 @@ export const TailoringWorkspace: React.FC<TailoringWorkspaceProps> = (
|
||||
setJobDescription,
|
||||
selectedIds,
|
||||
selectedIdsCsv,
|
||||
tracerLinksEnabled,
|
||||
setTracerLinksEnabled,
|
||||
skillsDraft,
|
||||
openSkillGroupId,
|
||||
setOpenSkillGroupId,
|
||||
@ -68,6 +71,16 @@ export const TailoringWorkspace: React.FC<TailoringWorkspaceProps> = (
|
||||
const [isSummarizing, setIsSummarizing] = useState(false);
|
||||
const [isGeneratingPdf, setIsGeneratingPdf] = 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(
|
||||
() => ({
|
||||
@ -76,8 +89,16 @@ export const TailoringWorkspace: React.FC<TailoringWorkspaceProps> = (
|
||||
tailoredSkills: skillsJson,
|
||||
jobDescription,
|
||||
selectedProjectIds: selectedIdsCsv,
|
||||
tracerLinksEnabled,
|
||||
}),
|
||||
[summary, headline, skillsJson, jobDescription, selectedIdsCsv],
|
||||
[
|
||||
summary,
|
||||
headline,
|
||||
skillsJson,
|
||||
jobDescription,
|
||||
selectedIdsCsv,
|
||||
tracerLinksEnabled,
|
||||
],
|
||||
);
|
||||
|
||||
const persistCurrent = useCallback(async () => {
|
||||
@ -176,8 +197,16 @@ export const TailoringWorkspace: React.FC<TailoringWorkspaceProps> = (
|
||||
await api.generateJobPdf(props.job.id);
|
||||
toast.success("Resume PDF generated");
|
||||
await editorProps.onUpdate();
|
||||
} catch {
|
||||
toast.error("PDF generation failed");
|
||||
} catch (error) {
|
||||
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 {
|
||||
setIsGeneratingPdf(false);
|
||||
}
|
||||
@ -256,6 +285,10 @@ export const TailoringWorkspace: React.FC<TailoringWorkspaceProps> = (
|
||||
jobDescription={jobDescription}
|
||||
skillsDraft={skillsDraft}
|
||||
selectedIds={selectedIds}
|
||||
tracerLinksEnabled={tracerLinksEnabled}
|
||||
tracerEnableBlocked={tracerEnableBlocked}
|
||||
tracerEnableBlockedReason={tracerEnableBlockedReason}
|
||||
tracerReadinessChecking={isTracerReadinessChecking}
|
||||
openSkillGroupId={openSkillGroupId}
|
||||
disableInputs={disableInputs}
|
||||
onSummaryChange={setSummary}
|
||||
@ -266,6 +299,7 @@ export const TailoringWorkspace: React.FC<TailoringWorkspaceProps> = (
|
||||
onUpdateSkillGroup={handleUpdateSkillGroup}
|
||||
onRemoveSkillGroup={handleRemoveSkillGroup}
|
||||
onToggleProject={handleToggleProject}
|
||||
onTracerLinksEnabledChange={setTracerLinksEnabled}
|
||||
/>
|
||||
|
||||
<div className="flex justify-end border-t pt-4">
|
||||
@ -342,6 +376,10 @@ export const TailoringWorkspace: React.FC<TailoringWorkspaceProps> = (
|
||||
jobDescription={jobDescription}
|
||||
skillsDraft={skillsDraft}
|
||||
selectedIds={selectedIds}
|
||||
tracerLinksEnabled={tracerLinksEnabled}
|
||||
tracerEnableBlocked={tracerEnableBlocked}
|
||||
tracerEnableBlockedReason={tracerEnableBlockedReason}
|
||||
tracerReadinessChecking={isTracerReadinessChecking}
|
||||
openSkillGroupId={openSkillGroupId}
|
||||
disableInputs={disableInputs}
|
||||
onSummaryChange={setSummary}
|
||||
@ -352,6 +390,7 @@ export const TailoringWorkspace: React.FC<TailoringWorkspaceProps> = (
|
||||
onUpdateSkillGroup={handleUpdateSkillGroup}
|
||||
onRemoveSkillGroup={handleRemoveSkillGroup}
|
||||
onToggleProject={handleToggleProject}
|
||||
onTracerLinksEnabledChange={setTracerLinksEnabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@ -32,6 +32,7 @@ const parseIncomingDraft = (incomingJob: Job) => {
|
||||
const skillsJson = serializeTailoredSkills(
|
||||
fromEditableSkillGroups(skillsDraft),
|
||||
);
|
||||
const tracerLinksEnabled = Boolean(incomingJob.tracerLinksEnabled);
|
||||
|
||||
return {
|
||||
summary,
|
||||
@ -40,6 +41,7 @@ const parseIncomingDraft = (incomingJob: Job) => {
|
||||
selectedIds,
|
||||
skillsDraft,
|
||||
skillsJson,
|
||||
tracerLinksEnabled,
|
||||
};
|
||||
};
|
||||
|
||||
@ -65,6 +67,9 @@ export function useTailoringDraft({
|
||||
toEditableSkillGroups(parseTailoredSkills(job.tailoredSkills)),
|
||||
);
|
||||
const [openSkillGroupId, setOpenSkillGroupId] = useState<string>("");
|
||||
const [tracerLinksEnabled, setTracerLinksEnabled] = useState(
|
||||
Boolean(job.tracerLinksEnabled),
|
||||
);
|
||||
|
||||
const [savedSummary, setSavedSummary] = useState(job.tailoredSummary || "");
|
||||
const [savedHeadline, setSavedHeadline] = useState(
|
||||
@ -79,6 +84,9 @@ export function useTailoringDraft({
|
||||
const [savedSkillsJson, setSavedSkillsJson] = useState(() =>
|
||||
serializeTailoredSkills(parseTailoredSkills(job.tailoredSkills)),
|
||||
);
|
||||
const [savedTracerLinksEnabled, setSavedTracerLinksEnabled] = useState(
|
||||
Boolean(job.tracerLinksEnabled),
|
||||
);
|
||||
|
||||
const lastJobIdRef = useRef(job.id);
|
||||
const jobRef = useRef(job);
|
||||
@ -98,6 +106,7 @@ export function useTailoringDraft({
|
||||
if (headline !== savedHeadline) return true;
|
||||
if (jobDescription !== savedDescription) return true;
|
||||
if (skillsJson !== savedSkillsJson) return true;
|
||||
if (tracerLinksEnabled !== savedTracerLinksEnabled) return true;
|
||||
return hasSelectionDiff(selectedIds, savedSelectedIds);
|
||||
}, [
|
||||
summary,
|
||||
@ -108,6 +117,8 @@ export function useTailoringDraft({
|
||||
savedDescription,
|
||||
skillsJson,
|
||||
savedSkillsJson,
|
||||
tracerLinksEnabled,
|
||||
savedTracerLinksEnabled,
|
||||
selectedIds,
|
||||
savedSelectedIds,
|
||||
]);
|
||||
@ -124,6 +135,8 @@ export function useTailoringDraft({
|
||||
setSavedDescription(next.description);
|
||||
setSavedSelectedIds(next.selectedIds);
|
||||
setSavedSkillsJson(next.skillsJson);
|
||||
setTracerLinksEnabled(next.tracerLinksEnabled);
|
||||
setSavedTracerLinksEnabled(next.tracerLinksEnabled);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@ -210,6 +223,8 @@ export function useTailoringDraft({
|
||||
openSkillGroupId,
|
||||
setOpenSkillGroupId,
|
||||
skillsJson,
|
||||
tracerLinksEnabled,
|
||||
setTracerLinksEnabled,
|
||||
isDirty,
|
||||
applyIncomingDraft,
|
||||
handleToggleProject,
|
||||
|
||||
101
orchestrator/src/client/hooks/useTracerReadiness.ts
Normal file
101
orchestrator/src/client/hooks/useTracerReadiness.ts
Normal 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();
|
||||
}
|
||||
@ -4,6 +4,7 @@ import { MemoryRouter } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import * as api from "../api";
|
||||
import { _resetTracerReadinessCache } from "../hooks/useTracerReadiness";
|
||||
import { SettingsPage } from "./SettingsPage";
|
||||
|
||||
vi.mock("../api", () => ({
|
||||
@ -11,6 +12,10 @@ vi.mock("../api", () => ({
|
||||
updateSettings: vi.fn(),
|
||||
clearDatabase: vi.fn(),
|
||||
deleteJobsByStatus: vi.fn(),
|
||||
getTracerReadiness: vi.fn(),
|
||||
getBackups: vi.fn().mockResolvedValue({ backups: [], nextScheduled: null }),
|
||||
createManualBackup: vi.fn(),
|
||||
deleteBackup: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("sonner", () => ({
|
||||
@ -78,6 +83,16 @@ const renderPage = () => {
|
||||
describe("SettingsPage", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
_resetTracerReadinessCache();
|
||||
vi.mocked(api.getTracerReadiness).mockResolvedValue({
|
||||
status: "ready",
|
||||
canEnable: true,
|
||||
publicBaseUrl: "https://my-jobops.example.com",
|
||||
healthUrl: "https://my-jobops.example.com/health",
|
||||
checkedAt: Date.now(),
|
||||
lastSuccessAt: Date.now(),
|
||||
reason: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("saves trimmed model overrides", async () => {
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import * as api from "@client/api";
|
||||
import { PageHeader } from "@client/components/layout";
|
||||
import { useTracerReadiness } from "@client/hooks/useTracerReadiness";
|
||||
import { BackupSettingsSection } from "@client/pages/settings/components/BackupSettingsSection";
|
||||
import { ChatSettingsSection } from "@client/pages/settings/components/ChatSettingsSection";
|
||||
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 { ReactiveResumeSection } from "@client/pages/settings/components/ReactiveResumeSection";
|
||||
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 {
|
||||
type LlmProviderId,
|
||||
@ -312,6 +314,12 @@ export const SettingsPage: React.FC = () => {
|
||||
const [isLoadingBackups, setIsLoadingBackups] = useState(false);
|
||||
const [isCreatingBackup, setIsCreatingBackup] = useState(false);
|
||||
const [isDeletingBackup, setIsDeletingBackup] = useState(false);
|
||||
const {
|
||||
readiness: tracerReadiness,
|
||||
isLoading: isTracerReadinessLoading,
|
||||
isChecking: isTracerReadinessChecking,
|
||||
refreshReadiness,
|
||||
} = useTracerReadiness();
|
||||
|
||||
const methods = useForm<UpdateSettingsInput>({
|
||||
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
|
||||
useEffect(() => {
|
||||
if (settings) {
|
||||
@ -779,6 +807,12 @@ export const SettingsPage: React.FC = () => {
|
||||
isLoading={isLoading}
|
||||
isSaving={isSaving}
|
||||
/>
|
||||
<TracerLinksSettingsSection
|
||||
readiness={tracerReadiness}
|
||||
isLoading={isLoading || isTracerReadinessLoading}
|
||||
isChecking={isTracerReadinessChecking}
|
||||
onVerifyNow={handleVerifyTracerReadiness}
|
||||
/>
|
||||
<DisplaySettingsSection
|
||||
values={display}
|
||||
isLoading={isLoading}
|
||||
|
||||
644
orchestrator/src/client/pages/TracerLinksPage.tsx
Normal file
644
orchestrator/src/client/pages/TracerLinksPage.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -15,6 +15,7 @@ import { postApplicationProvidersRouter } from "./routes/post-application-provid
|
||||
import { postApplicationReviewRouter } from "./routes/post-application-review";
|
||||
import { profileRouter } from "./routes/profile";
|
||||
import { settingsRouter } from "./routes/settings";
|
||||
import { tracerLinksRouter } from "./routes/tracer-links";
|
||||
import { visaSponsorsRouter } from "./routes/visa-sponsors";
|
||||
import { webhookRouter } from "./routes/webhook";
|
||||
|
||||
@ -34,3 +35,4 @@ apiRouter.use("/database", databaseRouter);
|
||||
apiRouter.use("/visa-sponsors", visaSponsorsRouter);
|
||||
apiRouter.use("/onboarding", onboardingRouter);
|
||||
apiRouter.use("/backups", backupRouter);
|
||||
apiRouter.use("/tracer-links", tracerLinksRouter);
|
||||
|
||||
@ -175,6 +175,104 @@ describe.sequential("Jobs API routes", () => {
|
||||
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 () => {
|
||||
const res = await fetch(`${baseUrl}/api/jobs/missing-id`, {
|
||||
method: "PATCH",
|
||||
@ -189,6 +287,42 @@ describe.sequential("Jobs API routes", () => {
|
||||
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 () => {
|
||||
const { createJob } = await import("../../repositories/jobs");
|
||||
const first = await createJob({
|
||||
|
||||
@ -43,6 +43,7 @@ import {
|
||||
} from "../../services/demo-simulator";
|
||||
import { getProfile } from "../../services/profile";
|
||||
import { scoreJobSuitability } from "../../services/scorer";
|
||||
import { getTracerReadiness } from "../../services/tracer-links";
|
||||
import * as visaSponsors from "../../services/visa-sponsors/index";
|
||||
|
||||
export const jobsRouter = Router();
|
||||
@ -163,6 +164,7 @@ const updateJobSchema = z.object({
|
||||
}),
|
||||
selectedProjectIds: z.string().optional(),
|
||||
pdfPath: z.string().optional(),
|
||||
tracerLinksEnabled: z.boolean().optional(),
|
||||
sponsorMatchScore: z.number().min(0).max(100).optional(),
|
||||
sponsorMatchNames: z.string().optional(),
|
||||
});
|
||||
@ -217,6 +219,36 @@ function parseStatusFilter(statusFilter?: string): JobStatus[] | 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): {
|
||||
code: string;
|
||||
message: string;
|
||||
@ -832,6 +864,51 @@ jobsRouter.patch("/:id/outcome", async (req: Request, res: Response) => {
|
||||
jobsRouter.patch("/:id", async (req: Request, res: Response) => {
|
||||
try {
|
||||
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);
|
||||
|
||||
if (!job) {
|
||||
@ -1031,7 +1108,9 @@ jobsRouter.post("/:id/generate-pdf", async (req: Request, res: Response) => {
|
||||
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) {
|
||||
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 });
|
||||
}
|
||||
|
||||
const result = await processJob(req.params.id, { force });
|
||||
const result = await processJob(req.params.id, {
|
||||
force,
|
||||
requestOrigin: resolveRequestOrigin(req),
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
return res.status(400).json({ success: false, error: result.error });
|
||||
|
||||
246
orchestrator/src/server/api/routes/tracer-links.test.ts
Normal file
246
orchestrator/src/server/api/routes/tracer-links.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
177
orchestrator/src/server/api/routes/tracer-links.ts
Normal file
177
orchestrator/src/server/api/routes/tracer-links.ts
Normal 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);
|
||||
}),
|
||||
);
|
||||
@ -20,6 +20,7 @@ import express from "express";
|
||||
import { apiRouter } from "./api/index";
|
||||
import { getDataDir } from "./config/dataDir";
|
||||
import { isDemoMode } from "./config/demo";
|
||||
import { resolveTracerRedirect } from "./services/tracer-links";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
@ -66,6 +67,9 @@ function createBasicAuthGuard() {
|
||||
|
||||
function requiresAuth(method: string, path: string): boolean {
|
||||
if (isPublicReadOnlyRoute(method, path)) return false;
|
||||
if (path.startsWith("/api/tracer-links")) {
|
||||
return method.toUpperCase() !== "OPTIONS";
|
||||
}
|
||||
return !["GET", "HEAD", "OPTIONS"].includes(method.toUpperCase());
|
||||
}
|
||||
|
||||
@ -91,6 +95,50 @@ export function createApp() {
|
||||
const app = express();
|
||||
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(requestContextMiddleware());
|
||||
app.use(express.json({ limit: "5mb" }));
|
||||
@ -118,6 +166,15 @@ export function createApp() {
|
||||
app.use("/api", apiRouter);
|
||||
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
|
||||
const pdfDir = join(getDataDir(), "pdfs");
|
||||
if (isDemoMode()) {
|
||||
|
||||
@ -67,7 +67,11 @@ const migrations = [
|
||||
suitability_score REAL,
|
||||
suitability_reason TEXT,
|
||||
tailored_summary TEXT,
|
||||
tailored_headline TEXT,
|
||||
tailored_skills TEXT,
|
||||
selected_project_ids TEXT,
|
||||
pdf_path TEXT,
|
||||
tracer_links_enabled INTEGER NOT NULL DEFAULT 0,
|
||||
discovered_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
processed_at TEXT,
|
||||
applied_at TEXT,
|
||||
@ -244,6 +248,36 @@ const migrations = [
|
||||
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)
|
||||
`INSERT OR REPLACE INTO settings(key, value, created_at, updated_at)
|
||||
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 tailored_headline 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
|
||||
`ALTER TABLE jobs ADD COLUMN sponsor_match_score REAL`,
|
||||
@ -403,6 +438,7 @@ const migrations = [
|
||||
tailored_skills TEXT,
|
||||
selected_project_ids TEXT,
|
||||
pdf_path TEXT,
|
||||
tracer_links_enabled INTEGER NOT NULL DEFAULT 0,
|
||||
sponsor_match_score REAL,
|
||||
sponsor_match_names TEXT,
|
||||
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,
|
||||
deadline, salary, location, degree_required, starting, job_description, status, outcome, closed_at,
|
||||
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
|
||||
)
|
||||
SELECT
|
||||
@ -430,7 +466,7 @@ const migrations = [
|
||||
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,
|
||||
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
|
||||
FROM 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_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_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.
|
||||
`WITH ranked AS (
|
||||
SELECT
|
||||
|
||||
@ -110,6 +110,9 @@ export const jobs = sqliteTable("jobs", {
|
||||
tailoredSkills: text("tailored_skills"),
|
||||
selectedProjectIds: text("selected_project_ids"),
|
||||
pdfPath: text("pdf_path"),
|
||||
tracerLinksEnabled: integer("tracer_links_enabled", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false),
|
||||
sponsorMatchScore: real("sponsor_match_score"),
|
||||
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 NewJobRow = typeof jobs.$inferInsert;
|
||||
export type StageEventRow = typeof stageEvents.$inferSelect;
|
||||
@ -415,3 +477,7 @@ export type PostApplicationMessageRow =
|
||||
typeof postApplicationMessages.$inferSelect;
|
||||
export type NewPostApplicationMessageRow =
|
||||
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;
|
||||
|
||||
@ -223,6 +223,7 @@ export async function runPipeline(
|
||||
|
||||
export type ProcessJobOptions = {
|
||||
force?: boolean;
|
||||
requestOrigin?: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
@ -323,7 +324,7 @@ export async function summarizeJob(
|
||||
*/
|
||||
export async function generateFinalPdf(
|
||||
jobId: string,
|
||||
_options?: ProcessJobOptions,
|
||||
options?: ProcessJobOptions,
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
error?: string;
|
||||
@ -348,6 +349,11 @@ export async function generateFinalPdf(
|
||||
job.jobDescription || "",
|
||||
undefined, // deprecated baseResumePath parameter
|
||||
job.selectedProjectIds,
|
||||
{
|
||||
tracerLinksEnabled: job.tracerLinksEnabled,
|
||||
requestOrigin: options?.requestOrigin ?? null,
|
||||
tracerCompanyName: job.employer ?? null,
|
||||
},
|
||||
);
|
||||
|
||||
if (!pdfResult.success) {
|
||||
|
||||
@ -399,6 +399,7 @@ function mapRowToJob(row: typeof jobs.$inferSelect): Job {
|
||||
tailoredSkills: row.tailoredSkills ?? null,
|
||||
selectedProjectIds: row.selectedProjectIds ?? null,
|
||||
pdfPath: row.pdfPath,
|
||||
tracerLinksEnabled: row.tracerLinksEnabled ?? false,
|
||||
sponsorMatchScore: row.sponsorMatchScore ?? null,
|
||||
sponsorMatchNames: row.sponsorMatchNames ?? null,
|
||||
jobType: row.jobType ?? null,
|
||||
|
||||
92
orchestrator/src/server/repositories/tracer-links.test.ts
Normal file
92
orchestrator/src/server/repositories/tracer-links.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
494
orchestrator/src/server/repositories/tracer-links.ts
Normal file
494
orchestrator/src/server/repositories/tracer-links.ts
Normal 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),
|
||||
};
|
||||
});
|
||||
}
|
||||
@ -2,6 +2,8 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { generatePdf } from "./pdf";
|
||||
import { getProfile } from "./profile";
|
||||
|
||||
process.env.DATA_DIR = "/tmp";
|
||||
|
||||
// Define mock data in hoisted block
|
||||
const { mocks, mockProfile, mockRxResumeClient } = vi.hoisted(() => {
|
||||
const profile = {
|
||||
@ -85,6 +87,7 @@ vi.mock("node:fs/promises", async () => {
|
||||
|
||||
vi.mock("fs", () => ({
|
||||
existsSync: vi.fn().mockReturnValue(true),
|
||||
mkdirSync: vi.fn(),
|
||||
createWriteStream: vi.fn().mockReturnValue({
|
||||
on: vi.fn(),
|
||||
write: vi.fn(),
|
||||
@ -92,6 +95,7 @@ vi.mock("fs", () => ({
|
||||
}),
|
||||
default: {
|
||||
existsSync: vi.fn().mockReturnValue(true),
|
||||
mkdirSync: vi.fn(),
|
||||
createWriteStream: vi.fn().mockReturnValue({
|
||||
on: vi.fn(),
|
||||
write: vi.fn(),
|
||||
@ -102,6 +106,7 @@ vi.mock("fs", () => ({
|
||||
|
||||
vi.mock("node:fs", () => ({
|
||||
existsSync: vi.fn().mockReturnValue(true),
|
||||
mkdirSync: vi.fn(),
|
||||
createWriteStream: vi.fn().mockReturnValue({
|
||||
on: vi.fn(),
|
||||
write: vi.fn(),
|
||||
@ -109,6 +114,7 @@ vi.mock("node:fs", () => ({
|
||||
}),
|
||||
default: {
|
||||
existsSync: vi.fn().mockReturnValue(true),
|
||||
mkdirSync: vi.fn(),
|
||||
createWriteStream: vi.fn().mockReturnValue({
|
||||
on: vi.fn(),
|
||||
write: vi.fn(),
|
||||
@ -135,6 +141,13 @@ vi.mock("./projectSelection", () => ({
|
||||
pickProjectIdsForJob: vi.fn().mockResolvedValue([]),
|
||||
}));
|
||||
|
||||
vi.mock("./tracer-links", () => ({
|
||||
resolveTracerPublicBaseUrl: vi.fn().mockReturnValue("https://jobops.example"),
|
||||
rewriteResumeLinksWithTracer: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ rewrittenLinks: 0 }),
|
||||
}));
|
||||
|
||||
vi.mock("./resumeProjects", () => ({
|
||||
extractProjectsFromProfile: vi
|
||||
.fn()
|
||||
|
||||
@ -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
|
||||
vi.mock("./rxresume-client", () => ({
|
||||
RxResumeClient: vi.fn().mockImplementation(function (this: any) {
|
||||
@ -214,6 +226,12 @@ describe("PDF Service Tailoring Logic", () => {
|
||||
vi.clearAllMocks();
|
||||
mocks.readFile.mockResolvedValue(JSON.stringify(mockProfile));
|
||||
mockRxResumeClient.clearLastCreateData();
|
||||
mockTracerLinks.resolveTracerPublicBaseUrl.mockReturnValue(
|
||||
"https://jobops.example",
|
||||
);
|
||||
mockTracerLinks.rewriteResumeLinksWithTracer.mockResolvedValue({
|
||||
rewrittenLinks: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it("should use provided selectedProjectIds and BYPASS AI selection", async () => {
|
||||
@ -281,4 +299,27 @@ describe("PDF Service Tailoring Logic", () => {
|
||||
).length;
|
||||
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,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@ -17,6 +17,10 @@ import {
|
||||
resolveResumeProjectsSettings,
|
||||
} from "./resumeProjects";
|
||||
import { RxResumeClient } from "./rxresume-client";
|
||||
import {
|
||||
resolveTracerPublicBaseUrl,
|
||||
rewriteResumeLinksWithTracer,
|
||||
} from "./tracer-links";
|
||||
|
||||
const OUTPUT_DIR = join(getDataDir(), "pdfs");
|
||||
|
||||
@ -32,6 +36,12 @@ export interface TailoredPdfContent {
|
||||
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.
|
||||
*/
|
||||
@ -104,6 +114,7 @@ export async function generatePdf(
|
||||
jobDescription: string,
|
||||
_baseResumePath?: string, // Deprecated: now always uses getProfile() which fetches from v4 API
|
||||
selectedProjectIds?: string | null,
|
||||
options?: GeneratePdfOptions,
|
||||
): Promise<PdfResult> {
|
||||
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
|
||||
const outputPath = join(OUTPUT_DIR, `resume_${jobId}.pdf`);
|
||||
|
||||
|
||||
306
orchestrator/src/server/services/tracer-links.test.ts
Normal file
306
orchestrator/src/server/services/tracer-links.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
622
orchestrator/src/server/services/tracer-links.ts
Normal file
622
orchestrator/src/server/services/tracer-links.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@ -56,6 +56,10 @@ describe("Tailoring Flow", () => {
|
||||
"Senior TypeScript Developer", // Original JD
|
||||
undefined, // Deprecated profile path
|
||||
"project-a,project-c", // The manually selected projects
|
||||
expect.objectContaining({
|
||||
requestOrigin: null,
|
||||
tracerLinksEnabled: undefined,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@ -86,6 +90,10 @@ describe("Tailoring Flow", () => {
|
||||
"Junior Java Developer",
|
||||
undefined, // Deprecated profile path
|
||||
undefined, // No projects selected
|
||||
expect.objectContaining({
|
||||
requestOrigin: null,
|
||||
tracerLinksEnabled: undefined,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@ -35,6 +35,7 @@ export const createJob = (overrides: Partial<Job> = {}): Job => ({
|
||||
tailoredSkills: null,
|
||||
selectedProjectIds: null,
|
||||
pdfPath: null,
|
||||
tracerLinksEnabled: false,
|
||||
sponsorMatchScore: null,
|
||||
sponsorMatchNames: null,
|
||||
jobType: null,
|
||||
|
||||
@ -162,6 +162,7 @@ export interface Job {
|
||||
tailoredSkills: string | null; // Generated resume skills (JSON)
|
||||
selectedProjectIds: string | null; // Comma-separated IDs of selected projects
|
||||
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
|
||||
sponsorMatchNames: string | null; // JSON array of matched sponsor names (when 100% matches or top match)
|
||||
|
||||
@ -317,6 +318,7 @@ export interface UpdateJobInput {
|
||||
tailoredSkills?: string;
|
||||
selectedProjectIds?: string;
|
||||
pdfPath?: string;
|
||||
tracerLinksEnabled?: boolean;
|
||||
appliedAt?: string;
|
||||
sponsorMatchScore?: number;
|
||||
sponsorMatchNames?: string;
|
||||
@ -368,6 +370,105 @@ export type ApiResponse<T> =
|
||||
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 type PostApplicationProvider =
|
||||
(typeof POST_APPLICATION_PROVIDERS)[number];
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user