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_USER=
|
||||||
BASIC_AUTH_PASSWORD=
|
BASIC_AUTH_PASSWORD=
|
||||||
|
|
||||||
|
# Public base URL used to generate tracer links when PDFs are created by
|
||||||
|
# background/pipeline runs (where request host cannot be inferred).
|
||||||
|
# Example: JOBOPS_PUBLIC_BASE_URL=https://jobops.example.com
|
||||||
|
JOBOPS_PUBLIC_BASE_URL=
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Gmail OAuth (Tracking Inbox) - optional
|
# Gmail OAuth (Tracking Inbox) - optional
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|||||||
@ -123,9 +123,27 @@ High-level flow:
|
|||||||
1. Load selected base resume from RxResume.
|
1. Load selected base resume from RxResume.
|
||||||
2. Apply tailored summary/headline/skills.
|
2. Apply tailored summary/headline/skills.
|
||||||
3. Compute final visible projects from your selection rules.
|
3. Compute final visible projects from your selection rules.
|
||||||
4. Create temporary resume in RxResume.
|
4. Optionally rewrite outbound links to tracer links (per-job toggle).
|
||||||
5. Export PDF.
|
5. Create temporary resume in RxResume.
|
||||||
6. Delete temporary resume.
|
6. Export PDF.
|
||||||
|
7. Delete temporary resume.
|
||||||
|
|
||||||
|
### Per-job tracer links
|
||||||
|
|
||||||
|
Before generating a PDF, each job can enable/disable tracer links.
|
||||||
|
|
||||||
|
- Disabled: original RxResume links remain unchanged.
|
||||||
|
- Enabled: eligible outbound links are rewritten to `https://<your-host>/cv/<company>-xx` (readable slug + 2-letter suffix).
|
||||||
|
|
||||||
|
For background pipeline generation, configure:
|
||||||
|
|
||||||
|
- `JOBOPS_PUBLIC_BASE_URL=https://your-host`
|
||||||
|
|
||||||
|
Important:
|
||||||
|
|
||||||
|
- tracer enablement is gated by readiness checks
|
||||||
|
- if public host verification fails, enable is blocked until host health is restored
|
||||||
|
- toggle changes apply on next PDF generation only
|
||||||
|
|
||||||
### What JobOps changes with AI
|
### What JobOps changes with AI
|
||||||
|
|
||||||
|
|||||||
@ -18,6 +18,7 @@ It lets you configure:
|
|||||||
- Display and Ghostwriter defaults
|
- Display and Ghostwriter defaults
|
||||||
- Service credentials and basic auth
|
- Service credentials and basic auth
|
||||||
- Reactive Resume project selection
|
- Reactive Resume project selection
|
||||||
|
- Tracer Links readiness verification
|
||||||
- Backup and scoring rules
|
- Backup and scoring rules
|
||||||
- Data-clearing actions in the Danger Zone
|
- Data-clearing actions in the Danger Zone
|
||||||
|
|
||||||
@ -81,6 +82,19 @@ Settings gives you runtime overrides for the key parts of discovery, scoring, ta
|
|||||||
- Must-include projects
|
- Must-include projects
|
||||||
- AI-selectable projects
|
- AI-selectable projects
|
||||||
|
|
||||||
|
### Tracer Links
|
||||||
|
|
||||||
|
- Verify tracer readiness before enabling per-job tracing
|
||||||
|
- Shows current status (`Ready`, `Unavailable`, `Unconfigured`, or stale state)
|
||||||
|
- Displays the effective public base URL and last check time
|
||||||
|
- Provides **Verify now** for an on-demand health check
|
||||||
|
|
||||||
|
Readiness requires:
|
||||||
|
|
||||||
|
- a valid public JobOps base URL
|
||||||
|
- successful reachability of `<public-base-url>/health`
|
||||||
|
- non-localhost/non-private host setup for public redirect usage
|
||||||
|
|
||||||
### Environment & Accounts
|
### Environment & Accounts
|
||||||
|
|
||||||
- Configure service accounts:
|
- Configure service accounts:
|
||||||
@ -163,6 +177,12 @@ curl -X POST "http://localhost:3001/api/backups"
|
|||||||
- Verify URL reachability from the server host.
|
- Verify URL reachability from the server host.
|
||||||
- Confirm auth expectations on the receiver side (including secret/bearer token).
|
- Confirm auth expectations on the receiver side (including secret/bearer token).
|
||||||
|
|
||||||
|
### Tracer links cannot be enabled
|
||||||
|
|
||||||
|
- Open **Settings → Tracer Links** and click **Verify now**.
|
||||||
|
- Ensure `JOBOPS_PUBLIC_BASE_URL` is set for background/pipeline usage.
|
||||||
|
- Ensure the configured host is publicly reachable and `/health` responds.
|
||||||
|
|
||||||
## Related pages
|
## Related pages
|
||||||
|
|
||||||
- [Reactive Resume](./reactive-resume)
|
- [Reactive Resume](./reactive-resume)
|
||||||
|
|||||||
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:
|
At this stage:
|
||||||
|
|
||||||
1. Open job details.
|
1. Open job details.
|
||||||
2. Download the tailored PDF.
|
2. Optionally enable tracer links for that specific job.
|
||||||
3. Submit your application externally.
|
3. Download the tailored PDF.
|
||||||
|
4. Submit your application externally.
|
||||||
|
|
||||||
### 5) Mark jobs as applied in JobOps
|
### 5) Mark jobs as applied in JobOps
|
||||||
|
|
||||||
@ -85,6 +86,8 @@ Once a job is marked `applied`, it becomes part of:
|
|||||||
- Increase tailored-job count only after score thresholds feel calibrated.
|
- Increase tailored-job count only after score thresholds feel calibrated.
|
||||||
- Expect scraper runtime variance by source.
|
- Expect scraper runtime variance by source.
|
||||||
- Keep resume/project context up to date so scoring/tailoring quality stays high.
|
- Keep resume/project context up to date so scoring/tailoring quality stays high.
|
||||||
|
- Use per-job tracer links when you want measurable outbound-link analytics.
|
||||||
|
- If you use tracer links, review the risk note in [Tracer Links](../features/tracer-links): some recipients/security tools may treat redirects as suspicious.
|
||||||
|
|
||||||
## Related pages
|
## Related pages
|
||||||
|
|
||||||
|
|||||||
@ -60,17 +60,12 @@
|
|||||||
{
|
{
|
||||||
"type": "category",
|
"type": "category",
|
||||||
"label": "Troubleshooting",
|
"label": "Troubleshooting",
|
||||||
"items": [
|
"items": ["troubleshooting/common-problems"]
|
||||||
"troubleshooting/common-problems"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "category",
|
"type": "category",
|
||||||
"label": "Reference / FAQ",
|
"label": "Reference / FAQ",
|
||||||
"items": [
|
"items": ["reference/faq", "reference/documentation-style-guide"]
|
||||||
"reference/faq",
|
|
||||||
"reference/documentation-style-guide"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 { JobPage } from "./pages/JobPage";
|
||||||
import { OrchestratorPage } from "./pages/OrchestratorPage";
|
import { OrchestratorPage } from "./pages/OrchestratorPage";
|
||||||
import { SettingsPage } from "./pages/SettingsPage";
|
import { SettingsPage } from "./pages/SettingsPage";
|
||||||
|
import { TracerLinksPage } from "./pages/TracerLinksPage";
|
||||||
import { TrackingInboxPage } from "./pages/TrackingInboxPage";
|
import { TrackingInboxPage } from "./pages/TrackingInboxPage";
|
||||||
import { VisaSponsorsPage } from "./pages/VisaSponsorsPage";
|
import { VisaSponsorsPage } from "./pages/VisaSponsorsPage";
|
||||||
|
|
||||||
@ -106,6 +107,7 @@ export const App: React.FC = () => {
|
|||||||
element={<InProgressBoardPage />}
|
element={<InProgressBoardPage />}
|
||||||
/>
|
/>
|
||||||
<Route path="/settings" element={<SettingsPage />} />
|
<Route path="/settings" element={<SettingsPage />} />
|
||||||
|
<Route path="/tracer-links" element={<TracerLinksPage />} />
|
||||||
<Route path="/visa-sponsors" element={<VisaSponsorsPage />} />
|
<Route path="/visa-sponsors" element={<VisaSponsorsPage />} />
|
||||||
<Route path="/tracking-inbox" element={<TrackingInboxPage />} />
|
<Route path="/tracking-inbox" element={<TrackingInboxPage />} />
|
||||||
<Route path="/jobs/:tab" element={<OrchestratorPage />} />
|
<Route path="/jobs/:tab" element={<OrchestratorPage />} />
|
||||||
|
|||||||
@ -24,6 +24,7 @@ import type {
|
|||||||
JobSource,
|
JobSource,
|
||||||
JobsListResponse,
|
JobsListResponse,
|
||||||
JobsRevisionResponse,
|
JobsRevisionResponse,
|
||||||
|
JobTracerLinksResponse,
|
||||||
ManualJobDraft,
|
ManualJobDraft,
|
||||||
ManualJobFetchResponse,
|
ManualJobFetchResponse,
|
||||||
ManualJobInferenceResponse,
|
ManualJobInferenceResponse,
|
||||||
@ -39,6 +40,8 @@ import type {
|
|||||||
StageEvent,
|
StageEvent,
|
||||||
StageEventMetadata,
|
StageEventMetadata,
|
||||||
StageTransitionTarget,
|
StageTransitionTarget,
|
||||||
|
TracerAnalyticsResponse,
|
||||||
|
TracerReadinessResponse,
|
||||||
ValidationResult,
|
ValidationResult,
|
||||||
VisaSponsor,
|
VisaSponsor,
|
||||||
VisaSponsorSearchResponse,
|
VisaSponsorSearchResponse,
|
||||||
@ -399,6 +402,69 @@ export async function updateJob(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getTracerAnalytics(options?: {
|
||||||
|
jobId?: string;
|
||||||
|
from?: number;
|
||||||
|
to?: number;
|
||||||
|
includeBots?: boolean;
|
||||||
|
limit?: number;
|
||||||
|
}): Promise<TracerAnalyticsResponse> {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (options?.jobId) params.set("jobId", options.jobId);
|
||||||
|
if (typeof options?.from === "number") {
|
||||||
|
params.set("from", String(options.from));
|
||||||
|
}
|
||||||
|
if (typeof options?.to === "number") {
|
||||||
|
params.set("to", String(options.to));
|
||||||
|
}
|
||||||
|
if (typeof options?.includeBots === "boolean") {
|
||||||
|
params.set("includeBots", options.includeBots ? "1" : "0");
|
||||||
|
}
|
||||||
|
if (typeof options?.limit === "number") {
|
||||||
|
params.set("limit", String(options.limit));
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = params.toString();
|
||||||
|
return fetchApi<TracerAnalyticsResponse>(
|
||||||
|
`/tracer-links/analytics${query ? `?${query}` : ""}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getTracerReadiness(options?: {
|
||||||
|
force?: boolean;
|
||||||
|
}): Promise<TracerReadinessResponse> {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (options?.force) params.set("force", "1");
|
||||||
|
const query = params.toString();
|
||||||
|
return fetchApi<TracerReadinessResponse>(
|
||||||
|
`/tracer-links/readiness${query ? `?${query}` : ""}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getJobTracerLinks(
|
||||||
|
jobId: string,
|
||||||
|
options?: {
|
||||||
|
from?: number;
|
||||||
|
to?: number;
|
||||||
|
includeBots?: boolean;
|
||||||
|
},
|
||||||
|
): Promise<JobTracerLinksResponse> {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (typeof options?.from === "number") {
|
||||||
|
params.set("from", String(options.from));
|
||||||
|
}
|
||||||
|
if (typeof options?.to === "number") {
|
||||||
|
params.set("to", String(options.to));
|
||||||
|
}
|
||||||
|
if (typeof options?.includeBots === "boolean") {
|
||||||
|
params.set("includeBots", options.includeBots ? "1" : "0");
|
||||||
|
}
|
||||||
|
const query = params.toString();
|
||||||
|
return fetchApi<JobTracerLinksResponse>(
|
||||||
|
`/tracer-links/jobs/${encodeURIComponent(jobId)}${query ? `?${query}` : ""}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async function streamSseEvents<TEvent>(
|
async function streamSseEvents<TEvent>(
|
||||||
endpoint: string,
|
endpoint: string,
|
||||||
input: StreamSseInput,
|
input: StreamSseInput,
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
|||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import * as api from "../api";
|
import * as api from "../api";
|
||||||
|
import { _resetTracerReadinessCache } from "../hooks/useTracerReadiness";
|
||||||
import { JobDetailsEditDrawer } from "./JobDetailsEditDrawer";
|
import { JobDetailsEditDrawer } from "./JobDetailsEditDrawer";
|
||||||
|
|
||||||
vi.mock("@/components/ui/sheet", () => ({
|
vi.mock("@/components/ui/sheet", () => ({
|
||||||
@ -27,6 +28,7 @@ vi.mock("../api", () => ({
|
|||||||
updateJob: vi.fn(),
|
updateJob: vi.fn(),
|
||||||
checkSponsor: vi.fn(),
|
checkSponsor: vi.fn(),
|
||||||
rescoreJob: vi.fn(),
|
rescoreJob: vi.fn(),
|
||||||
|
getTracerReadiness: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("sonner", () => ({
|
vi.mock("sonner", () => ({
|
||||||
@ -39,6 +41,16 @@ vi.mock("sonner", () => ({
|
|||||||
describe("JobDetailsEditDrawer", () => {
|
describe("JobDetailsEditDrawer", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
_resetTracerReadinessCache();
|
||||||
|
vi.mocked(api.getTracerReadiness).mockResolvedValue({
|
||||||
|
status: "ready",
|
||||||
|
canEnable: true,
|
||||||
|
publicBaseUrl: "https://my-jobops.example.com",
|
||||||
|
healthUrl: "https://my-jobops.example.com/health",
|
||||||
|
checkedAt: Date.now(),
|
||||||
|
lastSuccessAt: Date.now(),
|
||||||
|
reason: null,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("saves details and reruns sponsor check when employer changes", async () => {
|
it("saves details and reruns sponsor check when employer changes", async () => {
|
||||||
@ -138,4 +150,32 @@ describe("JobDetailsEditDrawer", () => {
|
|||||||
await waitFor(() => expect(api.rescoreJob).toHaveBeenCalledWith("job-1"));
|
await waitFor(() => expect(api.rescoreJob).toHaveBeenCalledWith("job-1"));
|
||||||
expect(onJobUpdated).toHaveBeenCalledTimes(2);
|
expect(onJobUpdated).toHaveBeenCalledTimes(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("persists tracer-links toggle with job updates", async () => {
|
||||||
|
const onJobUpdated = vi.fn().mockResolvedValue(undefined);
|
||||||
|
const onOpenChange = vi.fn();
|
||||||
|
vi.mocked(api.updateJob).mockResolvedValue({} as Job);
|
||||||
|
|
||||||
|
render(
|
||||||
|
<JobDetailsEditDrawer
|
||||||
|
open
|
||||||
|
onOpenChange={onOpenChange}
|
||||||
|
job={createJob({ tracerLinksEnabled: false })}
|
||||||
|
onJobUpdated={onJobUpdated}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => expect(api.getTracerReadiness).toHaveBeenCalled());
|
||||||
|
fireEvent.click(screen.getByLabelText("Enable tracer links for this job"));
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /save details/i }));
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(api.updateJob).toHaveBeenCalledWith(
|
||||||
|
"job-1",
|
||||||
|
expect.objectContaining({
|
||||||
|
tracerLinksEnabled: true,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import type React from "react";
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import {
|
import {
|
||||||
Sheet,
|
Sheet,
|
||||||
@ -14,6 +15,7 @@ import {
|
|||||||
} from "@/components/ui/sheet";
|
} from "@/components/ui/sheet";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import * as api from "../api";
|
import * as api from "../api";
|
||||||
|
import { useTracerReadiness } from "../hooks/useTracerReadiness";
|
||||||
|
|
||||||
interface JobDetailsEditDrawerProps {
|
interface JobDetailsEditDrawerProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@ -31,6 +33,7 @@ type JobDetailsDraft = {
|
|||||||
salary: string;
|
salary: string;
|
||||||
deadline: string;
|
deadline: string;
|
||||||
jobDescription: string;
|
jobDescription: string;
|
||||||
|
tracerLinksEnabled: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const emptyDraft: JobDetailsDraft = {
|
const emptyDraft: JobDetailsDraft = {
|
||||||
@ -42,6 +45,7 @@ const emptyDraft: JobDetailsDraft = {
|
|||||||
salary: "",
|
salary: "",
|
||||||
deadline: "",
|
deadline: "",
|
||||||
jobDescription: "",
|
jobDescription: "",
|
||||||
|
tracerLinksEnabled: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
const normalizeOptional = (value: string): string | null => {
|
const normalizeOptional = (value: string): string | null => {
|
||||||
@ -60,6 +64,7 @@ const normalizeFromJob = (job: Job | null): JobDetailsDraft => {
|
|||||||
salary: job.salary ?? "",
|
salary: job.salary ?? "",
|
||||||
deadline: job.deadline ?? "",
|
deadline: job.deadline ?? "",
|
||||||
jobDescription: job.jobDescription ?? "",
|
jobDescription: job.jobDescription ?? "",
|
||||||
|
tracerLinksEnabled: Boolean(job.tracerLinksEnabled),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -81,6 +86,8 @@ export const JobDetailsEditDrawer: React.FC<JobDetailsEditDrawerProps> = ({
|
|||||||
const [draft, setDraft] = useState<JobDetailsDraft>(emptyDraft);
|
const [draft, setDraft] = useState<JobDetailsDraft>(emptyDraft);
|
||||||
const [validationError, setValidationError] = useState<string | null>(null);
|
const [validationError, setValidationError] = useState<string | null>(null);
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
const { readiness: tracerReadiness, isChecking: isTracerReadinessChecking } =
|
||||||
|
useTracerReadiness();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
@ -90,6 +97,14 @@ export const JobDetailsEditDrawer: React.FC<JobDetailsEditDrawerProps> = ({
|
|||||||
}, [job, open]);
|
}, [job, open]);
|
||||||
|
|
||||||
const hasJob = !!job;
|
const hasJob = !!job;
|
||||||
|
const tracerCanEnable = Boolean(tracerReadiness?.canEnable);
|
||||||
|
const tracerEnableBlocked = !draft.tracerLinksEnabled && !tracerCanEnable;
|
||||||
|
const tracerEnableBlockedReason =
|
||||||
|
tracerReadiness?.canEnable === false
|
||||||
|
? (tracerReadiness.reason ??
|
||||||
|
"Tracer links are unavailable right now. Verify Tracer Links in Settings.")
|
||||||
|
: null;
|
||||||
|
|
||||||
const isDirty = useMemo(() => {
|
const isDirty = useMemo(() => {
|
||||||
if (!job) return false;
|
if (!job) return false;
|
||||||
const current = normalizeFromJob(job);
|
const current = normalizeFromJob(job);
|
||||||
@ -101,7 +116,8 @@ export const JobDetailsEditDrawer: React.FC<JobDetailsEditDrawerProps> = ({
|
|||||||
draft.location !== current.location ||
|
draft.location !== current.location ||
|
||||||
draft.salary !== current.salary ||
|
draft.salary !== current.salary ||
|
||||||
draft.deadline !== current.deadline ||
|
draft.deadline !== current.deadline ||
|
||||||
draft.jobDescription !== current.jobDescription
|
draft.jobDescription !== current.jobDescription ||
|
||||||
|
draft.tracerLinksEnabled !== current.tracerLinksEnabled
|
||||||
);
|
);
|
||||||
}, [draft, job]);
|
}, [draft, job]);
|
||||||
|
|
||||||
@ -133,6 +149,17 @@ export const JobDetailsEditDrawer: React.FC<JobDetailsEditDrawerProps> = ({
|
|||||||
setValidationError("Application URL must be a valid URL.");
|
setValidationError("Application URL must be a valid URL.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
draft.tracerLinksEnabled &&
|
||||||
|
!job.tracerLinksEnabled &&
|
||||||
|
!tracerCanEnable
|
||||||
|
) {
|
||||||
|
setValidationError(
|
||||||
|
tracerEnableBlockedReason ??
|
||||||
|
"Tracer links are unavailable right now. Verify Tracer Links in Settings.",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setValidationError(null);
|
setValidationError(null);
|
||||||
@ -150,6 +177,7 @@ export const JobDetailsEditDrawer: React.FC<JobDetailsEditDrawerProps> = ({
|
|||||||
salary: normalizeOptional(draft.salary),
|
salary: normalizeOptional(draft.salary),
|
||||||
deadline: normalizeOptional(draft.deadline),
|
deadline: normalizeOptional(draft.deadline),
|
||||||
jobDescription: normalizeOptional(draft.jobDescription),
|
jobDescription: normalizeOptional(draft.jobDescription),
|
||||||
|
tracerLinksEnabled: draft.tracerLinksEnabled,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (employerChanged) {
|
if (employerChanged) {
|
||||||
@ -281,6 +309,42 @@ export const JobDetailsEditDrawer: React.FC<JobDetailsEditDrawerProps> = ({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3 rounded-lg border border-border/60 bg-muted/10 px-3 py-3">
|
||||||
|
<label
|
||||||
|
htmlFor="edit-tracer-links-enabled"
|
||||||
|
className="flex cursor-pointer items-center gap-3"
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
id="edit-tracer-links-enabled"
|
||||||
|
checked={draft.tracerLinksEnabled}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
setDraft((prev) => ({
|
||||||
|
...prev,
|
||||||
|
tracerLinksEnabled: Boolean(checked),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
disabled={isSaving || tracerEnableBlocked}
|
||||||
|
/>
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
Enable tracer links for this job
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<p className="mt-2 text-xs text-muted-foreground">
|
||||||
|
{isTracerReadinessChecking
|
||||||
|
? "Checking tracer-link readiness..."
|
||||||
|
: "Applies on the next PDF generation. Existing PDFs are not modified."}
|
||||||
|
</p>
|
||||||
|
{tracerEnableBlockedReason && !draft.tracerLinksEnabled ? (
|
||||||
|
<p className="mt-2 text-xs text-destructive">
|
||||||
|
Tracer links are unavailable: {tracerEnableBlockedReason}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
<p className="mt-2 text-xs text-muted-foreground/80">
|
||||||
|
No raw IP is stored. Analytics are privacy-safe and
|
||||||
|
anonymous.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="mt-3 space-y-1">
|
<div className="mt-3 space-y-1">
|
||||||
<label
|
<label
|
||||||
htmlFor="edit-job-description"
|
htmlFor="edit-job-description"
|
||||||
|
|||||||
@ -45,6 +45,18 @@ const StatusPill: React.FC<{ status: JobStatus }> = ({ status }) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const TracerPill: React.FC<{ enabled: boolean }> = ({ enabled }) => (
|
||||||
|
<span className="inline-flex items-center gap-1.5 text-[10px] font-medium uppercase tracking-wide text-muted-foreground/80">
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"h-1.5 w-1.5 rounded-full opacity-80",
|
||||||
|
enabled ? "bg-violet-500" : "bg-slate-500",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{enabled ? "Tracer On" : "Tracer Off"}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
|
||||||
const ScoreMeter: React.FC<{ score: number | null }> = ({ score }) => {
|
const ScoreMeter: React.FC<{ score: number | null }> = ({ score }) => {
|
||||||
if (score == null) {
|
if (score == null) {
|
||||||
return <span className="text-[10px] text-muted-foreground/60">-</span>;
|
return <span className="text-[10px] text-muted-foreground/60">-</span>;
|
||||||
@ -256,6 +268,7 @@ export const JobHeader: React.FC<JobHeaderProps> = ({
|
|||||||
<div className="flex items-center justify-between gap-2 py-1 border-y border-border/30">
|
<div className="flex items-center justify-between gap-2 py-1 border-y border-border/30">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<StatusPill status={job.status} />
|
<StatusPill status={job.status} />
|
||||||
|
<TracerPill enabled={job.tracerLinksEnabled} />
|
||||||
{showSponsorInfo && (
|
{showSponsorInfo && (
|
||||||
<SponsorPill
|
<SponsorPill
|
||||||
score={job.sponsorMatchScore}
|
score={job.sponsorMatchScore}
|
||||||
|
|||||||
@ -175,7 +175,9 @@ describe("OnboardingGate", () => {
|
|||||||
|
|
||||||
await waitFor(() => expect(api.validateRxresume).toHaveBeenCalled());
|
await waitFor(() => expect(api.validateRxresume).toHaveBeenCalled());
|
||||||
expect(api.validateLlm).not.toHaveBeenCalled();
|
expect(api.validateLlm).not.toHaveBeenCalled();
|
||||||
expect(screen.getByText("Welcome to Job Ops")).toBeInTheDocument();
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("Welcome to Job Ops")).toBeInTheDocument();
|
||||||
|
});
|
||||||
expect(screen.queryByText("LLM API key")).not.toBeInTheDocument();
|
expect(screen.queryByText("LLM API key")).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import type { Job } from "@shared/types.js";
|
|||||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import * as api from "../api";
|
import * as api from "../api";
|
||||||
|
import { _resetTracerReadinessCache } from "../hooks/useTracerReadiness";
|
||||||
import { TailoringEditor } from "./TailoringEditor";
|
import { TailoringEditor } from "./TailoringEditor";
|
||||||
|
|
||||||
vi.mock("../api", () => ({
|
vi.mock("../api", () => ({
|
||||||
@ -10,6 +11,7 @@ vi.mock("../api", () => ({
|
|||||||
updateJob: vi.fn().mockResolvedValue({}),
|
updateJob: vi.fn().mockResolvedValue({}),
|
||||||
summarizeJob: vi.fn(),
|
summarizeJob: vi.fn(),
|
||||||
generateJobPdf: vi.fn(),
|
generateJobPdf: vi.fn(),
|
||||||
|
getTracerReadiness: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("sonner", () => ({
|
vi.mock("sonner", () => ({
|
||||||
@ -42,6 +44,16 @@ const ensureAccordionOpen = (name: string) => {
|
|||||||
describe("TailoringEditor", () => {
|
describe("TailoringEditor", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
_resetTracerReadinessCache();
|
||||||
|
vi.mocked(api.getTracerReadiness).mockResolvedValue({
|
||||||
|
status: "ready",
|
||||||
|
canEnable: true,
|
||||||
|
publicBaseUrl: "https://my-jobops.example.com",
|
||||||
|
healthUrl: "https://my-jobops.example.com/health",
|
||||||
|
checkedAt: Date.now(),
|
||||||
|
lastSuccessAt: Date.now(),
|
||||||
|
reason: null,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not rehydrate local edits from same-job prop updates", async () => {
|
it("does not rehydrate local edits from same-job prop updates", async () => {
|
||||||
@ -239,4 +251,30 @@ describe("TailoringEditor", () => {
|
|||||||
expect(screen.getByDisplayValue("Backend")).toBeInTheDocument();
|
expect(screen.getByDisplayValue("Backend")).toBeInTheDocument();
|
||||||
expect(screen.getByDisplayValue("Node.js, Kafka")).toBeInTheDocument();
|
expect(screen.getByDisplayValue("Node.js, Kafka")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("persists tracer-links toggle in tailoring save payload", async () => {
|
||||||
|
render(
|
||||||
|
<TailoringEditor
|
||||||
|
job={createJob({ tracerLinksEnabled: false })}
|
||||||
|
onUpdate={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(api.getResumeProjectsCatalog).toHaveBeenCalled(),
|
||||||
|
);
|
||||||
|
await waitFor(() => expect(api.getTracerReadiness).toHaveBeenCalled());
|
||||||
|
ensureAccordionOpen("Tracer Links");
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByLabelText("Enable tracer links for this job"));
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Save Selection" }));
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(api.updateJob).toHaveBeenCalledWith(
|
||||||
|
"job-1",
|
||||||
|
expect.objectContaining({
|
||||||
|
tracerLinksEnabled: true,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -3,12 +3,14 @@ import type { Job } from "@shared/types.js";
|
|||||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import * as api from "../../api";
|
import * as api from "../../api";
|
||||||
|
import { _resetTracerReadinessCache } from "../../hooks/useTracerReadiness";
|
||||||
import { TailorMode } from "./TailorMode";
|
import { TailorMode } from "./TailorMode";
|
||||||
|
|
||||||
vi.mock("../../api", () => ({
|
vi.mock("../../api", () => ({
|
||||||
getResumeProjectsCatalog: vi.fn().mockResolvedValue([]),
|
getResumeProjectsCatalog: vi.fn().mockResolvedValue([]),
|
||||||
updateJob: vi.fn(),
|
updateJob: vi.fn(),
|
||||||
summarizeJob: vi.fn(),
|
summarizeJob: vi.fn(),
|
||||||
|
getTracerReadiness: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("sonner", () => ({
|
vi.mock("sonner", () => ({
|
||||||
@ -41,6 +43,16 @@ const ensureAccordionOpen = (name: string) => {
|
|||||||
describe("TailorMode", () => {
|
describe("TailorMode", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
_resetTracerReadinessCache();
|
||||||
|
vi.mocked(api.getTracerReadiness).mockResolvedValue({
|
||||||
|
status: "ready",
|
||||||
|
canEnable: true,
|
||||||
|
publicBaseUrl: "https://my-jobops.example.com",
|
||||||
|
healthUrl: "https://my-jobops.example.com/health",
|
||||||
|
checkedAt: Date.now(),
|
||||||
|
lastSuccessAt: Date.now(),
|
||||||
|
reason: null,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not rehydrate local edits from same-job prop updates", async () => {
|
it("does not rehydrate local edits from same-job prop updates", async () => {
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import {
|
|||||||
Home,
|
Home,
|
||||||
Inbox,
|
Inbox,
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
|
Link2,
|
||||||
Settings,
|
Settings,
|
||||||
Shield,
|
Shield,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
@ -34,6 +35,12 @@ export const NAV_LINKS: NavLink[] = [
|
|||||||
activePaths: ["/applications/in-progress"],
|
activePaths: ["/applications/in-progress"],
|
||||||
},
|
},
|
||||||
{ to: "/tracking-inbox", label: "Tracking Inbox", icon: Inbox },
|
{ to: "/tracking-inbox", label: "Tracking Inbox", icon: Inbox },
|
||||||
|
{
|
||||||
|
to: "/tracer-links",
|
||||||
|
label: "Tracer Links",
|
||||||
|
icon: Link2,
|
||||||
|
activePaths: ["/tracer-links"],
|
||||||
|
},
|
||||||
{ to: "/visa-sponsors", label: "Visa Sponsors", icon: Shield },
|
{ to: "/visa-sponsors", label: "Visa Sponsors", icon: Shield },
|
||||||
{ to: "/settings", label: "Settings", icon: Settings },
|
{ to: "/settings", label: "Settings", icon: Settings },
|
||||||
];
|
];
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import {
|
|||||||
AccordionTrigger,
|
AccordionTrigger,
|
||||||
} from "@/components/ui/accordion";
|
} from "@/components/ui/accordion";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { ProjectSelector } from "../discovered-panel/ProjectSelector";
|
import { ProjectSelector } from "../discovered-panel/ProjectSelector";
|
||||||
import type { EditableSkillGroup } from "../tailoring-utils";
|
import type { EditableSkillGroup } from "../tailoring-utils";
|
||||||
|
|
||||||
@ -18,6 +19,10 @@ interface TailoringSectionsProps {
|
|||||||
jobDescription: string;
|
jobDescription: string;
|
||||||
skillsDraft: EditableSkillGroup[];
|
skillsDraft: EditableSkillGroup[];
|
||||||
selectedIds: Set<string>;
|
selectedIds: Set<string>;
|
||||||
|
tracerLinksEnabled: boolean;
|
||||||
|
tracerEnableBlocked: boolean;
|
||||||
|
tracerEnableBlockedReason: string | null;
|
||||||
|
tracerReadinessChecking?: boolean;
|
||||||
openSkillGroupId: string;
|
openSkillGroupId: string;
|
||||||
disableInputs: boolean;
|
disableInputs: boolean;
|
||||||
onSummaryChange: (value: string) => void;
|
onSummaryChange: (value: string) => void;
|
||||||
@ -32,6 +37,7 @@ interface TailoringSectionsProps {
|
|||||||
) => void;
|
) => void;
|
||||||
onRemoveSkillGroup: (id: string) => void;
|
onRemoveSkillGroup: (id: string) => void;
|
||||||
onToggleProject: (id: string) => void;
|
onToggleProject: (id: string) => void;
|
||||||
|
onTracerLinksEnabledChange: (value: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const sectionClass = "rounded-lg border border-border/60 bg-muted/20 px-0";
|
const sectionClass = "rounded-lg border border-border/60 bg-muted/20 px-0";
|
||||||
@ -47,6 +53,10 @@ export const TailoringSections: React.FC<TailoringSectionsProps> = ({
|
|||||||
jobDescription,
|
jobDescription,
|
||||||
skillsDraft,
|
skillsDraft,
|
||||||
selectedIds,
|
selectedIds,
|
||||||
|
tracerLinksEnabled,
|
||||||
|
tracerEnableBlocked,
|
||||||
|
tracerEnableBlockedReason,
|
||||||
|
tracerReadinessChecking = false,
|
||||||
openSkillGroupId,
|
openSkillGroupId,
|
||||||
disableInputs,
|
disableInputs,
|
||||||
onSummaryChange,
|
onSummaryChange,
|
||||||
@ -57,7 +67,11 @@ export const TailoringSections: React.FC<TailoringSectionsProps> = ({
|
|||||||
onUpdateSkillGroup,
|
onUpdateSkillGroup,
|
||||||
onRemoveSkillGroup,
|
onRemoveSkillGroup,
|
||||||
onToggleProject,
|
onToggleProject,
|
||||||
|
onTracerLinksEnabledChange,
|
||||||
}) => {
|
}) => {
|
||||||
|
const tracerToggleDisabled =
|
||||||
|
disableInputs || (!tracerLinksEnabled && tracerEnableBlocked);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Accordion type="multiple" className="space-y-3">
|
<Accordion type="multiple" className="space-y-3">
|
||||||
<AccordionItem value="job-description" className={sectionClass}>
|
<AccordionItem value="job-description" className={sectionClass}>
|
||||||
@ -239,6 +253,42 @@ export const TailoringSections: React.FC<TailoringSectionsProps> = ({
|
|||||||
/>
|
/>
|
||||||
</AccordionContent>
|
</AccordionContent>
|
||||||
</AccordionItem>
|
</AccordionItem>
|
||||||
|
|
||||||
|
<AccordionItem value="tracer-links" className={sectionClass}>
|
||||||
|
<AccordionTrigger className={triggerClass}>
|
||||||
|
Tracer Links
|
||||||
|
</AccordionTrigger>
|
||||||
|
<AccordionContent className="px-3 pb-3 pt-1">
|
||||||
|
<div className="rounded-md border border-border/60 bg-background/60 p-3">
|
||||||
|
<label
|
||||||
|
htmlFor="tailor-tracer-links-enabled"
|
||||||
|
className="flex cursor-pointer items-center gap-3"
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
id="tailor-tracer-links-enabled"
|
||||||
|
checked={tracerLinksEnabled}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
onTracerLinksEnabledChange(Boolean(checked))
|
||||||
|
}
|
||||||
|
disabled={tracerToggleDisabled}
|
||||||
|
/>
|
||||||
|
<span className="text-sm font-medium text-foreground">
|
||||||
|
Enable tracer links for this job
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<p className="mt-2 text-xs text-muted-foreground">
|
||||||
|
{tracerReadinessChecking
|
||||||
|
? "Checking tracer-link readiness..."
|
||||||
|
: "When enabled, outgoing resume links are rewritten to JobOps tracer links on the next PDF generation. Existing PDFs are unchanged."}
|
||||||
|
</p>
|
||||||
|
{tracerEnableBlockedReason && !tracerLinksEnabled ? (
|
||||||
|
<p className="mt-2 text-xs text-destructive">
|
||||||
|
Tracer links are unavailable: {tracerEnableBlockedReason}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
</Accordion>
|
</Accordion>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import { toast } from "sonner";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
import * as api from "../../api";
|
import * as api from "../../api";
|
||||||
|
import { useTracerReadiness } from "../../hooks/useTracerReadiness";
|
||||||
import { TailoringSections } from "./TailoringSections";
|
import { TailoringSections } from "./TailoringSections";
|
||||||
import { useTailoringDraft } from "./useTailoringDraft";
|
import { useTailoringDraft } from "./useTailoringDraft";
|
||||||
|
|
||||||
@ -49,6 +50,8 @@ export const TailoringWorkspace: React.FC<TailoringWorkspaceProps> = (
|
|||||||
setJobDescription,
|
setJobDescription,
|
||||||
selectedIds,
|
selectedIds,
|
||||||
selectedIdsCsv,
|
selectedIdsCsv,
|
||||||
|
tracerLinksEnabled,
|
||||||
|
setTracerLinksEnabled,
|
||||||
skillsDraft,
|
skillsDraft,
|
||||||
openSkillGroupId,
|
openSkillGroupId,
|
||||||
setOpenSkillGroupId,
|
setOpenSkillGroupId,
|
||||||
@ -68,6 +71,16 @@ export const TailoringWorkspace: React.FC<TailoringWorkspaceProps> = (
|
|||||||
const [isSummarizing, setIsSummarizing] = useState(false);
|
const [isSummarizing, setIsSummarizing] = useState(false);
|
||||||
const [isGeneratingPdf, setIsGeneratingPdf] = useState(false);
|
const [isGeneratingPdf, setIsGeneratingPdf] = useState(false);
|
||||||
const [isGenerating, setIsGenerating] = useState(false);
|
const [isGenerating, setIsGenerating] = useState(false);
|
||||||
|
const { readiness: tracerReadiness, isChecking: isTracerReadinessChecking } =
|
||||||
|
useTracerReadiness();
|
||||||
|
|
||||||
|
const tracerEnableBlocked =
|
||||||
|
!tracerLinksEnabled && !tracerReadiness?.canEnable;
|
||||||
|
const tracerEnableBlockedReason =
|
||||||
|
tracerReadiness?.canEnable === false
|
||||||
|
? (tracerReadiness.reason ??
|
||||||
|
"Verify tracer links in Settings before enabling this job.")
|
||||||
|
: null;
|
||||||
|
|
||||||
const savePayload = useMemo(
|
const savePayload = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
@ -76,8 +89,16 @@ export const TailoringWorkspace: React.FC<TailoringWorkspaceProps> = (
|
|||||||
tailoredSkills: skillsJson,
|
tailoredSkills: skillsJson,
|
||||||
jobDescription,
|
jobDescription,
|
||||||
selectedProjectIds: selectedIdsCsv,
|
selectedProjectIds: selectedIdsCsv,
|
||||||
|
tracerLinksEnabled,
|
||||||
}),
|
}),
|
||||||
[summary, headline, skillsJson, jobDescription, selectedIdsCsv],
|
[
|
||||||
|
summary,
|
||||||
|
headline,
|
||||||
|
skillsJson,
|
||||||
|
jobDescription,
|
||||||
|
selectedIdsCsv,
|
||||||
|
tracerLinksEnabled,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const persistCurrent = useCallback(async () => {
|
const persistCurrent = useCallback(async () => {
|
||||||
@ -176,8 +197,16 @@ export const TailoringWorkspace: React.FC<TailoringWorkspaceProps> = (
|
|||||||
await api.generateJobPdf(props.job.id);
|
await api.generateJobPdf(props.job.id);
|
||||||
toast.success("Resume PDF generated");
|
toast.success("Resume PDF generated");
|
||||||
await editorProps.onUpdate();
|
await editorProps.onUpdate();
|
||||||
} catch {
|
} catch (error) {
|
||||||
toast.error("PDF generation failed");
|
const message =
|
||||||
|
error instanceof Error ? error.message : "PDF generation failed";
|
||||||
|
if (/tracer/i.test(message)) {
|
||||||
|
toast.error("Tracer links are unavailable right now", {
|
||||||
|
description: message,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
toast.error(message);
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setIsGeneratingPdf(false);
|
setIsGeneratingPdf(false);
|
||||||
}
|
}
|
||||||
@ -256,6 +285,10 @@ export const TailoringWorkspace: React.FC<TailoringWorkspaceProps> = (
|
|||||||
jobDescription={jobDescription}
|
jobDescription={jobDescription}
|
||||||
skillsDraft={skillsDraft}
|
skillsDraft={skillsDraft}
|
||||||
selectedIds={selectedIds}
|
selectedIds={selectedIds}
|
||||||
|
tracerLinksEnabled={tracerLinksEnabled}
|
||||||
|
tracerEnableBlocked={tracerEnableBlocked}
|
||||||
|
tracerEnableBlockedReason={tracerEnableBlockedReason}
|
||||||
|
tracerReadinessChecking={isTracerReadinessChecking}
|
||||||
openSkillGroupId={openSkillGroupId}
|
openSkillGroupId={openSkillGroupId}
|
||||||
disableInputs={disableInputs}
|
disableInputs={disableInputs}
|
||||||
onSummaryChange={setSummary}
|
onSummaryChange={setSummary}
|
||||||
@ -266,6 +299,7 @@ export const TailoringWorkspace: React.FC<TailoringWorkspaceProps> = (
|
|||||||
onUpdateSkillGroup={handleUpdateSkillGroup}
|
onUpdateSkillGroup={handleUpdateSkillGroup}
|
||||||
onRemoveSkillGroup={handleRemoveSkillGroup}
|
onRemoveSkillGroup={handleRemoveSkillGroup}
|
||||||
onToggleProject={handleToggleProject}
|
onToggleProject={handleToggleProject}
|
||||||
|
onTracerLinksEnabledChange={setTracerLinksEnabled}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex justify-end border-t pt-4">
|
<div className="flex justify-end border-t pt-4">
|
||||||
@ -342,6 +376,10 @@ export const TailoringWorkspace: React.FC<TailoringWorkspaceProps> = (
|
|||||||
jobDescription={jobDescription}
|
jobDescription={jobDescription}
|
||||||
skillsDraft={skillsDraft}
|
skillsDraft={skillsDraft}
|
||||||
selectedIds={selectedIds}
|
selectedIds={selectedIds}
|
||||||
|
tracerLinksEnabled={tracerLinksEnabled}
|
||||||
|
tracerEnableBlocked={tracerEnableBlocked}
|
||||||
|
tracerEnableBlockedReason={tracerEnableBlockedReason}
|
||||||
|
tracerReadinessChecking={isTracerReadinessChecking}
|
||||||
openSkillGroupId={openSkillGroupId}
|
openSkillGroupId={openSkillGroupId}
|
||||||
disableInputs={disableInputs}
|
disableInputs={disableInputs}
|
||||||
onSummaryChange={setSummary}
|
onSummaryChange={setSummary}
|
||||||
@ -352,6 +390,7 @@ export const TailoringWorkspace: React.FC<TailoringWorkspaceProps> = (
|
|||||||
onUpdateSkillGroup={handleUpdateSkillGroup}
|
onUpdateSkillGroup={handleUpdateSkillGroup}
|
||||||
onRemoveSkillGroup={handleRemoveSkillGroup}
|
onRemoveSkillGroup={handleRemoveSkillGroup}
|
||||||
onToggleProject={handleToggleProject}
|
onToggleProject={handleToggleProject}
|
||||||
|
onTracerLinksEnabledChange={setTracerLinksEnabled}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -32,6 +32,7 @@ const parseIncomingDraft = (incomingJob: Job) => {
|
|||||||
const skillsJson = serializeTailoredSkills(
|
const skillsJson = serializeTailoredSkills(
|
||||||
fromEditableSkillGroups(skillsDraft),
|
fromEditableSkillGroups(skillsDraft),
|
||||||
);
|
);
|
||||||
|
const tracerLinksEnabled = Boolean(incomingJob.tracerLinksEnabled);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
summary,
|
summary,
|
||||||
@ -40,6 +41,7 @@ const parseIncomingDraft = (incomingJob: Job) => {
|
|||||||
selectedIds,
|
selectedIds,
|
||||||
skillsDraft,
|
skillsDraft,
|
||||||
skillsJson,
|
skillsJson,
|
||||||
|
tracerLinksEnabled,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -65,6 +67,9 @@ export function useTailoringDraft({
|
|||||||
toEditableSkillGroups(parseTailoredSkills(job.tailoredSkills)),
|
toEditableSkillGroups(parseTailoredSkills(job.tailoredSkills)),
|
||||||
);
|
);
|
||||||
const [openSkillGroupId, setOpenSkillGroupId] = useState<string>("");
|
const [openSkillGroupId, setOpenSkillGroupId] = useState<string>("");
|
||||||
|
const [tracerLinksEnabled, setTracerLinksEnabled] = useState(
|
||||||
|
Boolean(job.tracerLinksEnabled),
|
||||||
|
);
|
||||||
|
|
||||||
const [savedSummary, setSavedSummary] = useState(job.tailoredSummary || "");
|
const [savedSummary, setSavedSummary] = useState(job.tailoredSummary || "");
|
||||||
const [savedHeadline, setSavedHeadline] = useState(
|
const [savedHeadline, setSavedHeadline] = useState(
|
||||||
@ -79,6 +84,9 @@ export function useTailoringDraft({
|
|||||||
const [savedSkillsJson, setSavedSkillsJson] = useState(() =>
|
const [savedSkillsJson, setSavedSkillsJson] = useState(() =>
|
||||||
serializeTailoredSkills(parseTailoredSkills(job.tailoredSkills)),
|
serializeTailoredSkills(parseTailoredSkills(job.tailoredSkills)),
|
||||||
);
|
);
|
||||||
|
const [savedTracerLinksEnabled, setSavedTracerLinksEnabled] = useState(
|
||||||
|
Boolean(job.tracerLinksEnabled),
|
||||||
|
);
|
||||||
|
|
||||||
const lastJobIdRef = useRef(job.id);
|
const lastJobIdRef = useRef(job.id);
|
||||||
const jobRef = useRef(job);
|
const jobRef = useRef(job);
|
||||||
@ -98,6 +106,7 @@ export function useTailoringDraft({
|
|||||||
if (headline !== savedHeadline) return true;
|
if (headline !== savedHeadline) return true;
|
||||||
if (jobDescription !== savedDescription) return true;
|
if (jobDescription !== savedDescription) return true;
|
||||||
if (skillsJson !== savedSkillsJson) return true;
|
if (skillsJson !== savedSkillsJson) return true;
|
||||||
|
if (tracerLinksEnabled !== savedTracerLinksEnabled) return true;
|
||||||
return hasSelectionDiff(selectedIds, savedSelectedIds);
|
return hasSelectionDiff(selectedIds, savedSelectedIds);
|
||||||
}, [
|
}, [
|
||||||
summary,
|
summary,
|
||||||
@ -108,6 +117,8 @@ export function useTailoringDraft({
|
|||||||
savedDescription,
|
savedDescription,
|
||||||
skillsJson,
|
skillsJson,
|
||||||
savedSkillsJson,
|
savedSkillsJson,
|
||||||
|
tracerLinksEnabled,
|
||||||
|
savedTracerLinksEnabled,
|
||||||
selectedIds,
|
selectedIds,
|
||||||
savedSelectedIds,
|
savedSelectedIds,
|
||||||
]);
|
]);
|
||||||
@ -124,6 +135,8 @@ export function useTailoringDraft({
|
|||||||
setSavedDescription(next.description);
|
setSavedDescription(next.description);
|
||||||
setSavedSelectedIds(next.selectedIds);
|
setSavedSelectedIds(next.selectedIds);
|
||||||
setSavedSkillsJson(next.skillsJson);
|
setSavedSkillsJson(next.skillsJson);
|
||||||
|
setTracerLinksEnabled(next.tracerLinksEnabled);
|
||||||
|
setSavedTracerLinksEnabled(next.tracerLinksEnabled);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -210,6 +223,8 @@ export function useTailoringDraft({
|
|||||||
openSkillGroupId,
|
openSkillGroupId,
|
||||||
setOpenSkillGroupId,
|
setOpenSkillGroupId,
|
||||||
skillsJson,
|
skillsJson,
|
||||||
|
tracerLinksEnabled,
|
||||||
|
setTracerLinksEnabled,
|
||||||
isDirty,
|
isDirty,
|
||||||
applyIncomingDraft,
|
applyIncomingDraft,
|
||||||
handleToggleProject,
|
handleToggleProject,
|
||||||
|
|||||||
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 { toast } from "sonner";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import * as api from "../api";
|
import * as api from "../api";
|
||||||
|
import { _resetTracerReadinessCache } from "../hooks/useTracerReadiness";
|
||||||
import { SettingsPage } from "./SettingsPage";
|
import { SettingsPage } from "./SettingsPage";
|
||||||
|
|
||||||
vi.mock("../api", () => ({
|
vi.mock("../api", () => ({
|
||||||
@ -11,6 +12,10 @@ vi.mock("../api", () => ({
|
|||||||
updateSettings: vi.fn(),
|
updateSettings: vi.fn(),
|
||||||
clearDatabase: vi.fn(),
|
clearDatabase: vi.fn(),
|
||||||
deleteJobsByStatus: vi.fn(),
|
deleteJobsByStatus: vi.fn(),
|
||||||
|
getTracerReadiness: vi.fn(),
|
||||||
|
getBackups: vi.fn().mockResolvedValue({ backups: [], nextScheduled: null }),
|
||||||
|
createManualBackup: vi.fn(),
|
||||||
|
deleteBackup: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("sonner", () => ({
|
vi.mock("sonner", () => ({
|
||||||
@ -78,6 +83,16 @@ const renderPage = () => {
|
|||||||
describe("SettingsPage", () => {
|
describe("SettingsPage", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
_resetTracerReadinessCache();
|
||||||
|
vi.mocked(api.getTracerReadiness).mockResolvedValue({
|
||||||
|
status: "ready",
|
||||||
|
canEnable: true,
|
||||||
|
publicBaseUrl: "https://my-jobops.example.com",
|
||||||
|
healthUrl: "https://my-jobops.example.com/health",
|
||||||
|
checkedAt: Date.now(),
|
||||||
|
lastSuccessAt: Date.now(),
|
||||||
|
reason: null,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("saves trimmed model overrides", async () => {
|
it("saves trimmed model overrides", async () => {
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import * as api from "@client/api";
|
import * as api from "@client/api";
|
||||||
import { PageHeader } from "@client/components/layout";
|
import { PageHeader } from "@client/components/layout";
|
||||||
|
import { useTracerReadiness } from "@client/hooks/useTracerReadiness";
|
||||||
import { BackupSettingsSection } from "@client/pages/settings/components/BackupSettingsSection";
|
import { BackupSettingsSection } from "@client/pages/settings/components/BackupSettingsSection";
|
||||||
import { ChatSettingsSection } from "@client/pages/settings/components/ChatSettingsSection";
|
import { ChatSettingsSection } from "@client/pages/settings/components/ChatSettingsSection";
|
||||||
import { DangerZoneSection } from "@client/pages/settings/components/DangerZoneSection";
|
import { DangerZoneSection } from "@client/pages/settings/components/DangerZoneSection";
|
||||||
@ -8,6 +9,7 @@ import { EnvironmentSettingsSection } from "@client/pages/settings/components/En
|
|||||||
import { ModelSettingsSection } from "@client/pages/settings/components/ModelSettingsSection";
|
import { ModelSettingsSection } from "@client/pages/settings/components/ModelSettingsSection";
|
||||||
import { ReactiveResumeSection } from "@client/pages/settings/components/ReactiveResumeSection";
|
import { ReactiveResumeSection } from "@client/pages/settings/components/ReactiveResumeSection";
|
||||||
import { ScoringSettingsSection } from "@client/pages/settings/components/ScoringSettingsSection";
|
import { ScoringSettingsSection } from "@client/pages/settings/components/ScoringSettingsSection";
|
||||||
|
import { TracerLinksSettingsSection } from "@client/pages/settings/components/TracerLinksSettingsSection";
|
||||||
import { WebhooksSection } from "@client/pages/settings/components/WebhooksSection";
|
import { WebhooksSection } from "@client/pages/settings/components/WebhooksSection";
|
||||||
import {
|
import {
|
||||||
type LlmProviderId,
|
type LlmProviderId,
|
||||||
@ -312,6 +314,12 @@ export const SettingsPage: React.FC = () => {
|
|||||||
const [isLoadingBackups, setIsLoadingBackups] = useState(false);
|
const [isLoadingBackups, setIsLoadingBackups] = useState(false);
|
||||||
const [isCreatingBackup, setIsCreatingBackup] = useState(false);
|
const [isCreatingBackup, setIsCreatingBackup] = useState(false);
|
||||||
const [isDeletingBackup, setIsDeletingBackup] = useState(false);
|
const [isDeletingBackup, setIsDeletingBackup] = useState(false);
|
||||||
|
const {
|
||||||
|
readiness: tracerReadiness,
|
||||||
|
isLoading: isTracerReadinessLoading,
|
||||||
|
isChecking: isTracerReadinessChecking,
|
||||||
|
refreshReadiness,
|
||||||
|
} = useTracerReadiness();
|
||||||
|
|
||||||
const methods = useForm<UpdateSettingsInput>({
|
const methods = useForm<UpdateSettingsInput>({
|
||||||
resolver: zodResolver(
|
resolver: zodResolver(
|
||||||
@ -486,6 +494,26 @@ export const SettingsPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleVerifyTracerReadiness = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const readiness = await refreshReadiness(true);
|
||||||
|
if (readiness.canEnable) {
|
||||||
|
toast.success("Tracer links are ready");
|
||||||
|
} else {
|
||||||
|
toast.error(
|
||||||
|
readiness.reason ??
|
||||||
|
"Tracer links are unavailable. Verify your public URL.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const message =
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "Failed to verify tracer-link readiness";
|
||||||
|
toast.error(message);
|
||||||
|
}
|
||||||
|
}, [refreshReadiness]);
|
||||||
|
|
||||||
// Load backups when settings are loaded
|
// Load backups when settings are loaded
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (settings) {
|
if (settings) {
|
||||||
@ -779,6 +807,12 @@ export const SettingsPage: React.FC = () => {
|
|||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
isSaving={isSaving}
|
isSaving={isSaving}
|
||||||
/>
|
/>
|
||||||
|
<TracerLinksSettingsSection
|
||||||
|
readiness={tracerReadiness}
|
||||||
|
isLoading={isLoading || isTracerReadinessLoading}
|
||||||
|
isChecking={isTracerReadinessChecking}
|
||||||
|
onVerifyNow={handleVerifyTracerReadiness}
|
||||||
|
/>
|
||||||
<DisplaySettingsSection
|
<DisplaySettingsSection
|
||||||
values={display}
|
values={display}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
|
|||||||
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 { postApplicationReviewRouter } from "./routes/post-application-review";
|
||||||
import { profileRouter } from "./routes/profile";
|
import { profileRouter } from "./routes/profile";
|
||||||
import { settingsRouter } from "./routes/settings";
|
import { settingsRouter } from "./routes/settings";
|
||||||
|
import { tracerLinksRouter } from "./routes/tracer-links";
|
||||||
import { visaSponsorsRouter } from "./routes/visa-sponsors";
|
import { visaSponsorsRouter } from "./routes/visa-sponsors";
|
||||||
import { webhookRouter } from "./routes/webhook";
|
import { webhookRouter } from "./routes/webhook";
|
||||||
|
|
||||||
@ -34,3 +35,4 @@ apiRouter.use("/database", databaseRouter);
|
|||||||
apiRouter.use("/visa-sponsors", visaSponsorsRouter);
|
apiRouter.use("/visa-sponsors", visaSponsorsRouter);
|
||||||
apiRouter.use("/onboarding", onboardingRouter);
|
apiRouter.use("/onboarding", onboardingRouter);
|
||||||
apiRouter.use("/backups", backupRouter);
|
apiRouter.use("/backups", backupRouter);
|
||||||
|
apiRouter.use("/tracer-links", tracerLinksRouter);
|
||||||
|
|||||||
@ -175,6 +175,104 @@ describe.sequential("Jobs API routes", () => {
|
|||||||
expect(typeof body.meta.requestId).toBe("string");
|
expect(typeof body.meta.requestId).toBe("string");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("blocks enabling tracer links when readiness check fails", async () => {
|
||||||
|
const { createJob } = await import("../../repositories/jobs");
|
||||||
|
const job = await createJob({
|
||||||
|
source: "manual",
|
||||||
|
title: "Tracer Blocked",
|
||||||
|
employer: "Example Co",
|
||||||
|
jobUrl: "https://example.com/job/tracer-blocked",
|
||||||
|
jobDescription: "Test description",
|
||||||
|
});
|
||||||
|
|
||||||
|
const previousBaseUrl = process.env.JOBOPS_PUBLIC_BASE_URL;
|
||||||
|
process.env.JOBOPS_PUBLIC_BASE_URL = "https://my-jobops.example.com";
|
||||||
|
const realFetch = global.fetch;
|
||||||
|
const mockFetch = vi.fn(async (input: any, init?: RequestInit) => {
|
||||||
|
const url = typeof input === "string" ? input : input.toString();
|
||||||
|
if (url === "https://my-jobops.example.com/health") {
|
||||||
|
return new Response("unavailable", { status: 503 });
|
||||||
|
}
|
||||||
|
return realFetch(input, init);
|
||||||
|
});
|
||||||
|
vi.stubGlobal("fetch", mockFetch);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${baseUrl}/api/jobs/${job.id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ tracerLinksEnabled: true }),
|
||||||
|
});
|
||||||
|
const body = await res.json();
|
||||||
|
|
||||||
|
expect(res.status).toBe(409);
|
||||||
|
expect(body.ok).toBe(false);
|
||||||
|
expect(body.error.code).toBe("CONFLICT");
|
||||||
|
expect(body.error.message).toMatch(/health check returned http 503/i);
|
||||||
|
expect(typeof body.meta.requestId).toBe("string");
|
||||||
|
} finally {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
if (previousBaseUrl === undefined) {
|
||||||
|
delete process.env.JOBOPS_PUBLIC_BASE_URL;
|
||||||
|
} else {
|
||||||
|
process.env.JOBOPS_PUBLIC_BASE_URL = previousBaseUrl;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows updates for already-enabled tracer links without re-gating", async () => {
|
||||||
|
const { createJob } = await import("../../repositories/jobs");
|
||||||
|
const { updateJob } = await import("../../repositories/jobs");
|
||||||
|
const job = await createJob({
|
||||||
|
source: "manual",
|
||||||
|
title: "Tracer Already On",
|
||||||
|
employer: "Example Co",
|
||||||
|
jobUrl: "https://example.com/job/tracer-enabled",
|
||||||
|
jobDescription: "Test description",
|
||||||
|
});
|
||||||
|
await updateJob(job.id, { tracerLinksEnabled: true });
|
||||||
|
|
||||||
|
const previousBaseUrl = process.env.JOBOPS_PUBLIC_BASE_URL;
|
||||||
|
process.env.JOBOPS_PUBLIC_BASE_URL = "https://my-jobops.example.com";
|
||||||
|
const realFetch = global.fetch;
|
||||||
|
const mockFetch = vi.fn(async (input: any, init?: RequestInit) => {
|
||||||
|
const url = typeof input === "string" ? input : input.toString();
|
||||||
|
if (url === "https://my-jobops.example.com/health") {
|
||||||
|
return new Response("unavailable", { status: 503 });
|
||||||
|
}
|
||||||
|
return realFetch(input, init);
|
||||||
|
});
|
||||||
|
vi.stubGlobal("fetch", mockFetch);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${baseUrl}/api/jobs/${job.id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
title: "Tracer Already On (Edited)",
|
||||||
|
tracerLinksEnabled: true,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const body = await res.json();
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(body.ok).toBe(true);
|
||||||
|
expect(body.data.title).toBe("Tracer Already On (Edited)");
|
||||||
|
expect(body.data.tracerLinksEnabled).toBe(true);
|
||||||
|
expect(mockFetch).not.toHaveBeenCalledWith(
|
||||||
|
"https://my-jobops.example.com/health",
|
||||||
|
expect.anything(),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
if (previousBaseUrl === undefined) {
|
||||||
|
delete process.env.JOBOPS_PUBLIC_BASE_URL;
|
||||||
|
} else {
|
||||||
|
process.env.JOBOPS_PUBLIC_BASE_URL = previousBaseUrl;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("returns 404 when patching a missing job", async () => {
|
it("returns 404 when patching a missing job", async () => {
|
||||||
const res = await fetch(`${baseUrl}/api/jobs/missing-id`, {
|
const res = await fetch(`${baseUrl}/api/jobs/missing-id`, {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
@ -189,6 +287,42 @@ describe.sequential("Jobs API routes", () => {
|
|||||||
expect(typeof body.meta.requestId).toBe("string");
|
expect(typeof body.meta.requestId).toBe("string");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("prefers JOBOPS_PUBLIC_BASE_URL over forwarded headers for generate-pdf origin", async () => {
|
||||||
|
const { createJob } = await import("../../repositories/jobs");
|
||||||
|
const { generateFinalPdf } = await import("../../pipeline/index");
|
||||||
|
const job = await createJob({
|
||||||
|
source: "manual",
|
||||||
|
title: "Origin Test",
|
||||||
|
employer: "Example Co",
|
||||||
|
jobUrl: "https://example.com/job/origin-test",
|
||||||
|
jobDescription: "Test description",
|
||||||
|
});
|
||||||
|
|
||||||
|
const previousBaseUrl = process.env.JOBOPS_PUBLIC_BASE_URL;
|
||||||
|
process.env.JOBOPS_PUBLIC_BASE_URL = "https://canonical.jobops.example";
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${baseUrl}/api/jobs/${job.id}/generate-pdf`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"x-forwarded-proto": "http",
|
||||||
|
"x-forwarded-host": "attacker.example",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(vi.mocked(generateFinalPdf)).toHaveBeenCalledWith(job.id, {
|
||||||
|
requestOrigin: "https://canonical.jobops.example",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
if (previousBaseUrl === undefined) {
|
||||||
|
delete process.env.JOBOPS_PUBLIC_BASE_URL;
|
||||||
|
} else {
|
||||||
|
process.env.JOBOPS_PUBLIC_BASE_URL = previousBaseUrl;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("returns 409 when patching to a duplicate job URL", async () => {
|
it("returns 409 when patching to a duplicate job URL", async () => {
|
||||||
const { createJob } = await import("../../repositories/jobs");
|
const { createJob } = await import("../../repositories/jobs");
|
||||||
const first = await createJob({
|
const first = await createJob({
|
||||||
|
|||||||
@ -43,6 +43,7 @@ import {
|
|||||||
} from "../../services/demo-simulator";
|
} from "../../services/demo-simulator";
|
||||||
import { getProfile } from "../../services/profile";
|
import { getProfile } from "../../services/profile";
|
||||||
import { scoreJobSuitability } from "../../services/scorer";
|
import { scoreJobSuitability } from "../../services/scorer";
|
||||||
|
import { getTracerReadiness } from "../../services/tracer-links";
|
||||||
import * as visaSponsors from "../../services/visa-sponsors/index";
|
import * as visaSponsors from "../../services/visa-sponsors/index";
|
||||||
|
|
||||||
export const jobsRouter = Router();
|
export const jobsRouter = Router();
|
||||||
@ -163,6 +164,7 @@ const updateJobSchema = z.object({
|
|||||||
}),
|
}),
|
||||||
selectedProjectIds: z.string().optional(),
|
selectedProjectIds: z.string().optional(),
|
||||||
pdfPath: z.string().optional(),
|
pdfPath: z.string().optional(),
|
||||||
|
tracerLinksEnabled: z.boolean().optional(),
|
||||||
sponsorMatchScore: z.number().min(0).max(100).optional(),
|
sponsorMatchScore: z.number().min(0).max(100).optional(),
|
||||||
sponsorMatchNames: z.string().optional(),
|
sponsorMatchNames: z.string().optional(),
|
||||||
});
|
});
|
||||||
@ -217,6 +219,36 @@ function parseStatusFilter(statusFilter?: string): JobStatus[] | undefined {
|
|||||||
return parsed && parsed.length > 0 ? parsed : undefined;
|
return parsed && parsed.length > 0 ? parsed : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveRequestOrigin(req: Request): string | null {
|
||||||
|
const configuredBaseUrl = process.env.JOBOPS_PUBLIC_BASE_URL?.trim();
|
||||||
|
if (configuredBaseUrl) {
|
||||||
|
try {
|
||||||
|
const parsed = new URL(configuredBaseUrl);
|
||||||
|
if (parsed.protocol && parsed.host) {
|
||||||
|
return `${parsed.protocol}//${parsed.host}`;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore invalid env and fall back to request-derived origin.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const trustProxy = Boolean(req.app?.get("trust proxy"));
|
||||||
|
let protocol = (req.protocol || "").trim();
|
||||||
|
let host = (req.header("host") || "").trim();
|
||||||
|
|
||||||
|
if (trustProxy) {
|
||||||
|
const forwardedProto =
|
||||||
|
req.header("x-forwarded-proto")?.split(",")[0]?.trim() ?? "";
|
||||||
|
const forwardedHost =
|
||||||
|
req.header("x-forwarded-host")?.split(",")[0]?.trim() ?? "";
|
||||||
|
if (forwardedProto) protocol = forwardedProto;
|
||||||
|
if (forwardedHost) host = forwardedHost;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!host || !protocol) return null;
|
||||||
|
return `${protocol}://${host}`;
|
||||||
|
}
|
||||||
|
|
||||||
function mapErrorForResult(error: unknown): {
|
function mapErrorForResult(error: unknown): {
|
||||||
code: string;
|
code: string;
|
||||||
message: string;
|
message: string;
|
||||||
@ -832,6 +864,51 @@ jobsRouter.patch("/:id/outcome", async (req: Request, res: Response) => {
|
|||||||
jobsRouter.patch("/:id", async (req: Request, res: Response) => {
|
jobsRouter.patch("/:id", async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const input = updateJobSchema.parse(req.body);
|
const input = updateJobSchema.parse(req.body);
|
||||||
|
const currentJob = await jobsRepo.getJobById(req.params.id);
|
||||||
|
|
||||||
|
if (!currentJob) {
|
||||||
|
const err = new AppError({
|
||||||
|
status: 404,
|
||||||
|
code: "NOT_FOUND",
|
||||||
|
message: "Job not found",
|
||||||
|
});
|
||||||
|
logger.warn("Job update failed", {
|
||||||
|
route: "PATCH /api/jobs/:id",
|
||||||
|
jobId: req.params.id,
|
||||||
|
status: err.status,
|
||||||
|
code: err.code,
|
||||||
|
});
|
||||||
|
fail(res, err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isTurningTracerLinksOn =
|
||||||
|
input.tracerLinksEnabled === true && !currentJob.tracerLinksEnabled;
|
||||||
|
|
||||||
|
if (isTurningTracerLinksOn) {
|
||||||
|
const readiness = await getTracerReadiness({
|
||||||
|
requestOrigin: resolveRequestOrigin(req),
|
||||||
|
force: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!readiness.canEnable) {
|
||||||
|
throw new AppError({
|
||||||
|
status: 409,
|
||||||
|
code: "CONFLICT",
|
||||||
|
message:
|
||||||
|
readiness.reason ??
|
||||||
|
"Tracer links are unavailable right now. Verify Tracer Links in Settings.",
|
||||||
|
details: {
|
||||||
|
tracerReadiness: {
|
||||||
|
status: readiness.status,
|
||||||
|
checkedAt: readiness.checkedAt,
|
||||||
|
publicBaseUrl: readiness.publicBaseUrl,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const job = await jobsRepo.updateJob(req.params.id, input);
|
const job = await jobsRepo.updateJob(req.params.id, input);
|
||||||
|
|
||||||
if (!job) {
|
if (!job) {
|
||||||
@ -1031,7 +1108,9 @@ jobsRouter.post("/:id/generate-pdf", async (req: Request, res: Response) => {
|
|||||||
return okWithMeta(res, job, { simulated: true });
|
return okWithMeta(res, job, { simulated: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await generateFinalPdf(req.params.id);
|
const result = await generateFinalPdf(req.params.id, {
|
||||||
|
requestOrigin: resolveRequestOrigin(req),
|
||||||
|
});
|
||||||
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
return res.status(400).json({ success: false, error: result.error });
|
return res.status(400).json({ success: false, error: result.error });
|
||||||
@ -1065,7 +1144,10 @@ jobsRouter.post("/:id/process", async (req: Request, res: Response) => {
|
|||||||
return okWithMeta(res, job, { simulated: true });
|
return okWithMeta(res, job, { simulated: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await processJob(req.params.id, { force });
|
const result = await processJob(req.params.id, {
|
||||||
|
force,
|
||||||
|
requestOrigin: resolveRequestOrigin(req),
|
||||||
|
});
|
||||||
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
return res.status(400).json({ success: false, error: result.error });
|
return res.status(400).json({ success: false, error: result.error });
|
||||||
|
|||||||
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 { apiRouter } from "./api/index";
|
||||||
import { getDataDir } from "./config/dataDir";
|
import { getDataDir } from "./config/dataDir";
|
||||||
import { isDemoMode } from "./config/demo";
|
import { isDemoMode } from "./config/demo";
|
||||||
|
import { resolveTracerRedirect } from "./services/tracer-links";
|
||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
@ -66,6 +67,9 @@ function createBasicAuthGuard() {
|
|||||||
|
|
||||||
function requiresAuth(method: string, path: string): boolean {
|
function requiresAuth(method: string, path: string): boolean {
|
||||||
if (isPublicReadOnlyRoute(method, path)) return false;
|
if (isPublicReadOnlyRoute(method, path)) return false;
|
||||||
|
if (path.startsWith("/api/tracer-links")) {
|
||||||
|
return method.toUpperCase() !== "OPTIONS";
|
||||||
|
}
|
||||||
return !["GET", "HEAD", "OPTIONS"].includes(method.toUpperCase());
|
return !["GET", "HEAD", "OPTIONS"].includes(method.toUpperCase());
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -91,6 +95,50 @@ export function createApp() {
|
|||||||
const app = express();
|
const app = express();
|
||||||
const authGuard = createBasicAuthGuard();
|
const authGuard = createBasicAuthGuard();
|
||||||
|
|
||||||
|
const handleTracerRedirect = async (
|
||||||
|
req: express.Request,
|
||||||
|
res: express.Response,
|
||||||
|
slug: string,
|
||||||
|
route: string,
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
const redirect = await resolveTracerRedirect({
|
||||||
|
token: slug,
|
||||||
|
requestId:
|
||||||
|
(res.getHeader("x-request-id") as string | undefined) ?? null,
|
||||||
|
ip: req.ip ?? null,
|
||||||
|
userAgent: req.header("user-agent") ?? null,
|
||||||
|
referrer: req.header("referer") ?? null,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!redirect) {
|
||||||
|
logger.warn("Tracer link not found", {
|
||||||
|
route,
|
||||||
|
token: slug,
|
||||||
|
});
|
||||||
|
res.status(404).type("text/plain; charset=utf-8").send("Not found");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("Tracer link redirected", {
|
||||||
|
route,
|
||||||
|
token: slug,
|
||||||
|
jobId: redirect.jobId,
|
||||||
|
});
|
||||||
|
res.set("Cache-Control", "no-store");
|
||||||
|
res.set("Pragma", "no-cache");
|
||||||
|
res.set("Expires", "0");
|
||||||
|
res.redirect(302, redirect.destinationUrl);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Tracer redirect failed", {
|
||||||
|
route,
|
||||||
|
token: slug,
|
||||||
|
error,
|
||||||
|
});
|
||||||
|
res.status(500).type("text/plain; charset=utf-8").send("Internal error");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
app.use(cors());
|
app.use(cors());
|
||||||
app.use(requestContextMiddleware());
|
app.use(requestContextMiddleware());
|
||||||
app.use(express.json({ limit: "5mb" }));
|
app.use(express.json({ limit: "5mb" }));
|
||||||
@ -118,6 +166,15 @@ export function createApp() {
|
|||||||
app.use("/api", apiRouter);
|
app.use("/api", apiRouter);
|
||||||
app.use(notFoundApiHandler());
|
app.use(notFoundApiHandler());
|
||||||
|
|
||||||
|
app.get("/cv/:slug", async (req, res) => {
|
||||||
|
const slug = req.params.slug?.trim();
|
||||||
|
if (!slug) {
|
||||||
|
res.status(404).type("text/plain; charset=utf-8").send("Not found");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await handleTracerRedirect(req, res, slug, "GET /cv/:slug");
|
||||||
|
});
|
||||||
|
|
||||||
// Serve static files for generated PDFs
|
// Serve static files for generated PDFs
|
||||||
const pdfDir = join(getDataDir(), "pdfs");
|
const pdfDir = join(getDataDir(), "pdfs");
|
||||||
if (isDemoMode()) {
|
if (isDemoMode()) {
|
||||||
|
|||||||
@ -67,7 +67,11 @@ const migrations = [
|
|||||||
suitability_score REAL,
|
suitability_score REAL,
|
||||||
suitability_reason TEXT,
|
suitability_reason TEXT,
|
||||||
tailored_summary TEXT,
|
tailored_summary TEXT,
|
||||||
|
tailored_headline TEXT,
|
||||||
|
tailored_skills TEXT,
|
||||||
|
selected_project_ids TEXT,
|
||||||
pdf_path TEXT,
|
pdf_path TEXT,
|
||||||
|
tracer_links_enabled INTEGER NOT NULL DEFAULT 0,
|
||||||
discovered_at TEXT NOT NULL DEFAULT (datetime('now')),
|
discovered_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
processed_at TEXT,
|
processed_at TEXT,
|
||||||
applied_at TEXT,
|
applied_at TEXT,
|
||||||
@ -244,6 +248,36 @@ const migrations = [
|
|||||||
UNIQUE(provider, account_key, external_message_id)
|
UNIQUE(provider, account_key, external_message_id)
|
||||||
)`,
|
)`,
|
||||||
|
|
||||||
|
`CREATE TABLE IF NOT EXISTS tracer_links (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
token TEXT NOT NULL UNIQUE,
|
||||||
|
job_id TEXT NOT NULL,
|
||||||
|
source_path TEXT NOT NULL,
|
||||||
|
source_label TEXT NOT NULL,
|
||||||
|
destination_url TEXT NOT NULL,
|
||||||
|
destination_url_hash TEXT NOT NULL,
|
||||||
|
is_active INTEGER NOT NULL DEFAULT 1,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
FOREIGN KEY (job_id) REFERENCES jobs(id) ON DELETE CASCADE,
|
||||||
|
UNIQUE(job_id, source_path, destination_url_hash)
|
||||||
|
)`,
|
||||||
|
|
||||||
|
`CREATE TABLE IF NOT EXISTS tracer_click_events (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
tracer_link_id TEXT NOT NULL,
|
||||||
|
clicked_at INTEGER NOT NULL,
|
||||||
|
request_id TEXT,
|
||||||
|
is_likely_bot INTEGER NOT NULL DEFAULT 0,
|
||||||
|
device_type TEXT NOT NULL DEFAULT 'unknown',
|
||||||
|
ua_family TEXT NOT NULL DEFAULT 'unknown',
|
||||||
|
os_family TEXT NOT NULL DEFAULT 'unknown',
|
||||||
|
referrer_host TEXT,
|
||||||
|
ip_hash TEXT,
|
||||||
|
unique_fingerprint_hash TEXT,
|
||||||
|
FOREIGN KEY (tracer_link_id) REFERENCES tracer_links(id) ON DELETE CASCADE
|
||||||
|
)`,
|
||||||
|
|
||||||
// Rename settings key: webhookUrl -> pipelineWebhookUrl (safe to re-run)
|
// Rename settings key: webhookUrl -> pipelineWebhookUrl (safe to re-run)
|
||||||
`INSERT OR REPLACE INTO settings(key, value, created_at, updated_at)
|
`INSERT OR REPLACE INTO settings(key, value, created_at, updated_at)
|
||||||
SELECT 'pipelineWebhookUrl', value, created_at, updated_at FROM settings WHERE key = 'webhookUrl'`,
|
SELECT 'pipelineWebhookUrl', value, created_at, updated_at FROM settings WHERE key = 'webhookUrl'`,
|
||||||
@ -293,6 +327,7 @@ const migrations = [
|
|||||||
`ALTER TABLE jobs ADD COLUMN selected_project_ids TEXT`,
|
`ALTER TABLE jobs ADD COLUMN selected_project_ids TEXT`,
|
||||||
`ALTER TABLE jobs ADD COLUMN tailored_headline TEXT`,
|
`ALTER TABLE jobs ADD COLUMN tailored_headline TEXT`,
|
||||||
`ALTER TABLE jobs ADD COLUMN tailored_skills TEXT`,
|
`ALTER TABLE jobs ADD COLUMN tailored_skills TEXT`,
|
||||||
|
`ALTER TABLE jobs ADD COLUMN tracer_links_enabled INTEGER NOT NULL DEFAULT 0`,
|
||||||
|
|
||||||
// Add sponsor match columns for visa sponsor matching feature
|
// Add sponsor match columns for visa sponsor matching feature
|
||||||
`ALTER TABLE jobs ADD COLUMN sponsor_match_score REAL`,
|
`ALTER TABLE jobs ADD COLUMN sponsor_match_score REAL`,
|
||||||
@ -403,6 +438,7 @@ const migrations = [
|
|||||||
tailored_skills TEXT,
|
tailored_skills TEXT,
|
||||||
selected_project_ids TEXT,
|
selected_project_ids TEXT,
|
||||||
pdf_path TEXT,
|
pdf_path TEXT,
|
||||||
|
tracer_links_enabled INTEGER NOT NULL DEFAULT 0,
|
||||||
sponsor_match_score REAL,
|
sponsor_match_score REAL,
|
||||||
sponsor_match_names TEXT,
|
sponsor_match_names TEXT,
|
||||||
discovered_at TEXT NOT NULL DEFAULT (datetime('now')),
|
discovered_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
@ -419,7 +455,7 @@ const migrations = [
|
|||||||
vacancy_count, work_from_home_type, title, employer, employer_url, job_url, application_link, disciplines,
|
vacancy_count, work_from_home_type, title, employer, employer_url, job_url, application_link, disciplines,
|
||||||
deadline, salary, location, degree_required, starting, job_description, status, outcome, closed_at,
|
deadline, salary, location, degree_required, starting, job_description, status, outcome, closed_at,
|
||||||
suitability_score, suitability_reason, tailored_summary, tailored_headline, tailored_skills,
|
suitability_score, suitability_reason, tailored_summary, tailored_headline, tailored_skills,
|
||||||
selected_project_ids, pdf_path, sponsor_match_score, sponsor_match_names, discovered_at, processed_at,
|
selected_project_ids, pdf_path, tracer_links_enabled, sponsor_match_score, sponsor_match_names, discovered_at, processed_at,
|
||||||
applied_at, created_at, updated_at
|
applied_at, created_at, updated_at
|
||||||
)
|
)
|
||||||
SELECT
|
SELECT
|
||||||
@ -430,7 +466,7 @@ const migrations = [
|
|||||||
vacancy_count, work_from_home_type, title, employer, employer_url, job_url, application_link, disciplines,
|
vacancy_count, work_from_home_type, title, employer, employer_url, job_url, application_link, disciplines,
|
||||||
deadline, salary, location, degree_required, starting, job_description, status, outcome, closed_at,
|
deadline, salary, location, degree_required, starting, job_description, status, outcome, closed_at,
|
||||||
suitability_score, suitability_reason, tailored_summary, tailored_headline, tailored_skills,
|
suitability_score, suitability_reason, tailored_summary, tailored_headline, tailored_skills,
|
||||||
selected_project_ids, pdf_path, sponsor_match_score, sponsor_match_names, discovered_at, processed_at,
|
selected_project_ids, pdf_path, tracer_links_enabled, sponsor_match_score, sponsor_match_names, discovered_at, processed_at,
|
||||||
applied_at, created_at, updated_at
|
applied_at, created_at, updated_at
|
||||||
FROM jobs`,
|
FROM jobs`,
|
||||||
`DROP TABLE IF EXISTS jobs`,
|
`DROP TABLE IF EXISTS jobs`,
|
||||||
@ -451,6 +487,12 @@ const migrations = [
|
|||||||
`CREATE INDEX IF NOT EXISTS idx_job_chat_threads_job_updated ON job_chat_threads(job_id, updated_at)`,
|
`CREATE INDEX IF NOT EXISTS idx_job_chat_threads_job_updated ON job_chat_threads(job_id, updated_at)`,
|
||||||
`CREATE INDEX IF NOT EXISTS idx_job_chat_messages_thread_created ON job_chat_messages(thread_id, created_at)`,
|
`CREATE INDEX IF NOT EXISTS idx_job_chat_messages_thread_created ON job_chat_messages(thread_id, created_at)`,
|
||||||
`CREATE INDEX IF NOT EXISTS idx_job_chat_runs_thread_status ON job_chat_runs(thread_id, status)`,
|
`CREATE INDEX IF NOT EXISTS idx_job_chat_runs_thread_status ON job_chat_runs(thread_id, status)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_tracer_links_token ON tracer_links(token)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_tracer_links_job_id ON tracer_links(job_id)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_tracer_click_events_tracer_link_id ON tracer_click_events(tracer_link_id)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_tracer_click_events_clicked_at ON tracer_click_events(clicked_at)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_tracer_click_events_is_likely_bot ON tracer_click_events(is_likely_bot)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_tracer_click_events_unique_fingerprint_hash ON tracer_click_events(unique_fingerprint_hash)`,
|
||||||
// Ensure only one running run per thread; backfill any duplicates first.
|
// Ensure only one running run per thread; backfill any duplicates first.
|
||||||
`WITH ranked AS (
|
`WITH ranked AS (
|
||||||
SELECT
|
SELECT
|
||||||
|
|||||||
@ -110,6 +110,9 @@ export const jobs = sqliteTable("jobs", {
|
|||||||
tailoredSkills: text("tailored_skills"),
|
tailoredSkills: text("tailored_skills"),
|
||||||
selectedProjectIds: text("selected_project_ids"),
|
selectedProjectIds: text("selected_project_ids"),
|
||||||
pdfPath: text("pdf_path"),
|
pdfPath: text("pdf_path"),
|
||||||
|
tracerLinksEnabled: integer("tracer_links_enabled", { mode: "boolean" })
|
||||||
|
.notNull()
|
||||||
|
.default(false),
|
||||||
sponsorMatchScore: real("sponsor_match_score"),
|
sponsorMatchScore: real("sponsor_match_score"),
|
||||||
sponsorMatchNames: text("sponsor_match_names"),
|
sponsorMatchNames: text("sponsor_match_names"),
|
||||||
|
|
||||||
@ -385,6 +388,65 @@ export const postApplicationMessages = sqliteTable(
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const tracerLinks = sqliteTable(
|
||||||
|
"tracer_links",
|
||||||
|
{
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
token: text("token").notNull().unique(),
|
||||||
|
jobId: text("job_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => jobs.id, { onDelete: "cascade" }),
|
||||||
|
sourcePath: text("source_path").notNull(),
|
||||||
|
sourceLabel: text("source_label").notNull(),
|
||||||
|
destinationUrl: text("destination_url").notNull(),
|
||||||
|
destinationUrlHash: text("destination_url_hash").notNull(),
|
||||||
|
isActive: integer("is_active", { mode: "boolean" }).notNull().default(true),
|
||||||
|
createdAt: text("created_at").notNull().default(sql`(datetime('now'))`),
|
||||||
|
updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`),
|
||||||
|
},
|
||||||
|
(table) => ({
|
||||||
|
jobPathDestinationUnique: uniqueIndex(
|
||||||
|
"idx_tracer_links_job_source_destination_unique",
|
||||||
|
).on(table.jobId, table.sourcePath, table.destinationUrlHash),
|
||||||
|
jobIndex: index("idx_tracer_links_job_id").on(table.jobId),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
export const tracerClickEvents = sqliteTable(
|
||||||
|
"tracer_click_events",
|
||||||
|
{
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
tracerLinkId: text("tracer_link_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => tracerLinks.id, { onDelete: "cascade" }),
|
||||||
|
clickedAt: integer("clicked_at", { mode: "number" }).notNull(),
|
||||||
|
requestId: text("request_id"),
|
||||||
|
isLikelyBot: integer("is_likely_bot", { mode: "boolean" })
|
||||||
|
.notNull()
|
||||||
|
.default(false),
|
||||||
|
deviceType: text("device_type").notNull().default("unknown"),
|
||||||
|
uaFamily: text("ua_family").notNull().default("unknown"),
|
||||||
|
osFamily: text("os_family").notNull().default("unknown"),
|
||||||
|
referrerHost: text("referrer_host"),
|
||||||
|
ipHash: text("ip_hash"),
|
||||||
|
uniqueFingerprintHash: text("unique_fingerprint_hash"),
|
||||||
|
},
|
||||||
|
(table) => ({
|
||||||
|
tracerLinkIndex: index("idx_tracer_click_events_tracer_link_id").on(
|
||||||
|
table.tracerLinkId,
|
||||||
|
),
|
||||||
|
clickedAtIndex: index("idx_tracer_click_events_clicked_at").on(
|
||||||
|
table.clickedAt,
|
||||||
|
),
|
||||||
|
botIndex: index("idx_tracer_click_events_is_likely_bot").on(
|
||||||
|
table.isLikelyBot,
|
||||||
|
),
|
||||||
|
uniqueFingerprintIndex: index(
|
||||||
|
"idx_tracer_click_events_unique_fingerprint_hash",
|
||||||
|
).on(table.uniqueFingerprintHash),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
export type JobRow = typeof jobs.$inferSelect;
|
export type JobRow = typeof jobs.$inferSelect;
|
||||||
export type NewJobRow = typeof jobs.$inferInsert;
|
export type NewJobRow = typeof jobs.$inferInsert;
|
||||||
export type StageEventRow = typeof stageEvents.$inferSelect;
|
export type StageEventRow = typeof stageEvents.$inferSelect;
|
||||||
@ -415,3 +477,7 @@ export type PostApplicationMessageRow =
|
|||||||
typeof postApplicationMessages.$inferSelect;
|
typeof postApplicationMessages.$inferSelect;
|
||||||
export type NewPostApplicationMessageRow =
|
export type NewPostApplicationMessageRow =
|
||||||
typeof postApplicationMessages.$inferInsert;
|
typeof postApplicationMessages.$inferInsert;
|
||||||
|
export type TracerLinkRow = typeof tracerLinks.$inferSelect;
|
||||||
|
export type NewTracerLinkRow = typeof tracerLinks.$inferInsert;
|
||||||
|
export type TracerClickEventRow = typeof tracerClickEvents.$inferSelect;
|
||||||
|
export type NewTracerClickEventRow = typeof tracerClickEvents.$inferInsert;
|
||||||
|
|||||||
@ -223,6 +223,7 @@ export async function runPipeline(
|
|||||||
|
|
||||||
export type ProcessJobOptions = {
|
export type ProcessJobOptions = {
|
||||||
force?: boolean;
|
force?: boolean;
|
||||||
|
requestOrigin?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -323,7 +324,7 @@ export async function summarizeJob(
|
|||||||
*/
|
*/
|
||||||
export async function generateFinalPdf(
|
export async function generateFinalPdf(
|
||||||
jobId: string,
|
jobId: string,
|
||||||
_options?: ProcessJobOptions,
|
options?: ProcessJobOptions,
|
||||||
): Promise<{
|
): Promise<{
|
||||||
success: boolean;
|
success: boolean;
|
||||||
error?: string;
|
error?: string;
|
||||||
@ -348,6 +349,11 @@ export async function generateFinalPdf(
|
|||||||
job.jobDescription || "",
|
job.jobDescription || "",
|
||||||
undefined, // deprecated baseResumePath parameter
|
undefined, // deprecated baseResumePath parameter
|
||||||
job.selectedProjectIds,
|
job.selectedProjectIds,
|
||||||
|
{
|
||||||
|
tracerLinksEnabled: job.tracerLinksEnabled,
|
||||||
|
requestOrigin: options?.requestOrigin ?? null,
|
||||||
|
tracerCompanyName: job.employer ?? null,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!pdfResult.success) {
|
if (!pdfResult.success) {
|
||||||
|
|||||||
@ -399,6 +399,7 @@ function mapRowToJob(row: typeof jobs.$inferSelect): Job {
|
|||||||
tailoredSkills: row.tailoredSkills ?? null,
|
tailoredSkills: row.tailoredSkills ?? null,
|
||||||
selectedProjectIds: row.selectedProjectIds ?? null,
|
selectedProjectIds: row.selectedProjectIds ?? null,
|
||||||
pdfPath: row.pdfPath,
|
pdfPath: row.pdfPath,
|
||||||
|
tracerLinksEnabled: row.tracerLinksEnabled ?? false,
|
||||||
sponsorMatchScore: row.sponsorMatchScore ?? null,
|
sponsorMatchScore: row.sponsorMatchScore ?? null,
|
||||||
sponsorMatchNames: row.sponsorMatchNames ?? null,
|
sponsorMatchNames: row.sponsorMatchNames ?? null,
|
||||||
jobType: row.jobType ?? null,
|
jobType: row.jobType ?? null,
|
||||||
|
|||||||
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 { generatePdf } from "./pdf";
|
||||||
import { getProfile } from "./profile";
|
import { getProfile } from "./profile";
|
||||||
|
|
||||||
|
process.env.DATA_DIR = "/tmp";
|
||||||
|
|
||||||
// Define mock data in hoisted block
|
// Define mock data in hoisted block
|
||||||
const { mocks, mockProfile, mockRxResumeClient } = vi.hoisted(() => {
|
const { mocks, mockProfile, mockRxResumeClient } = vi.hoisted(() => {
|
||||||
const profile = {
|
const profile = {
|
||||||
@ -85,6 +87,7 @@ vi.mock("node:fs/promises", async () => {
|
|||||||
|
|
||||||
vi.mock("fs", () => ({
|
vi.mock("fs", () => ({
|
||||||
existsSync: vi.fn().mockReturnValue(true),
|
existsSync: vi.fn().mockReturnValue(true),
|
||||||
|
mkdirSync: vi.fn(),
|
||||||
createWriteStream: vi.fn().mockReturnValue({
|
createWriteStream: vi.fn().mockReturnValue({
|
||||||
on: vi.fn(),
|
on: vi.fn(),
|
||||||
write: vi.fn(),
|
write: vi.fn(),
|
||||||
@ -92,6 +95,7 @@ vi.mock("fs", () => ({
|
|||||||
}),
|
}),
|
||||||
default: {
|
default: {
|
||||||
existsSync: vi.fn().mockReturnValue(true),
|
existsSync: vi.fn().mockReturnValue(true),
|
||||||
|
mkdirSync: vi.fn(),
|
||||||
createWriteStream: vi.fn().mockReturnValue({
|
createWriteStream: vi.fn().mockReturnValue({
|
||||||
on: vi.fn(),
|
on: vi.fn(),
|
||||||
write: vi.fn(),
|
write: vi.fn(),
|
||||||
@ -102,6 +106,7 @@ vi.mock("fs", () => ({
|
|||||||
|
|
||||||
vi.mock("node:fs", () => ({
|
vi.mock("node:fs", () => ({
|
||||||
existsSync: vi.fn().mockReturnValue(true),
|
existsSync: vi.fn().mockReturnValue(true),
|
||||||
|
mkdirSync: vi.fn(),
|
||||||
createWriteStream: vi.fn().mockReturnValue({
|
createWriteStream: vi.fn().mockReturnValue({
|
||||||
on: vi.fn(),
|
on: vi.fn(),
|
||||||
write: vi.fn(),
|
write: vi.fn(),
|
||||||
@ -109,6 +114,7 @@ vi.mock("node:fs", () => ({
|
|||||||
}),
|
}),
|
||||||
default: {
|
default: {
|
||||||
existsSync: vi.fn().mockReturnValue(true),
|
existsSync: vi.fn().mockReturnValue(true),
|
||||||
|
mkdirSync: vi.fn(),
|
||||||
createWriteStream: vi.fn().mockReturnValue({
|
createWriteStream: vi.fn().mockReturnValue({
|
||||||
on: vi.fn(),
|
on: vi.fn(),
|
||||||
write: vi.fn(),
|
write: vi.fn(),
|
||||||
@ -135,6 +141,13 @@ vi.mock("./projectSelection", () => ({
|
|||||||
pickProjectIdsForJob: vi.fn().mockResolvedValue([]),
|
pickProjectIdsForJob: vi.fn().mockResolvedValue([]),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock("./tracer-links", () => ({
|
||||||
|
resolveTracerPublicBaseUrl: vi.fn().mockReturnValue("https://jobops.example"),
|
||||||
|
rewriteResumeLinksWithTracer: vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue({ rewrittenLinks: 0 }),
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock("./resumeProjects", () => ({
|
vi.mock("./resumeProjects", () => ({
|
||||||
extractProjectsFromProfile: vi
|
extractProjectsFromProfile: vi
|
||||||
.fn()
|
.fn()
|
||||||
|
|||||||
@ -147,6 +147,18 @@ vi.mock("./resumeProjects", () => ({
|
|||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const mockTracerLinks = vi.hoisted(() => ({
|
||||||
|
resolveTracerPublicBaseUrl: vi.fn().mockReturnValue("https://jobops.example"),
|
||||||
|
rewriteResumeLinksWithTracer: vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue({ rewrittenLinks: 2 }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("./tracer-links", () => ({
|
||||||
|
resolveTracerPublicBaseUrl: mockTracerLinks.resolveTracerPublicBaseUrl,
|
||||||
|
rewriteResumeLinksWithTracer: mockTracerLinks.rewriteResumeLinksWithTracer,
|
||||||
|
}));
|
||||||
|
|
||||||
// Mock the RxResumeClient
|
// Mock the RxResumeClient
|
||||||
vi.mock("./rxresume-client", () => ({
|
vi.mock("./rxresume-client", () => ({
|
||||||
RxResumeClient: vi.fn().mockImplementation(function (this: any) {
|
RxResumeClient: vi.fn().mockImplementation(function (this: any) {
|
||||||
@ -214,6 +226,12 @@ describe("PDF Service Tailoring Logic", () => {
|
|||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
mocks.readFile.mockResolvedValue(JSON.stringify(mockProfile));
|
mocks.readFile.mockResolvedValue(JSON.stringify(mockProfile));
|
||||||
mockRxResumeClient.clearLastCreateData();
|
mockRxResumeClient.clearLastCreateData();
|
||||||
|
mockTracerLinks.resolveTracerPublicBaseUrl.mockReturnValue(
|
||||||
|
"https://jobops.example",
|
||||||
|
);
|
||||||
|
mockTracerLinks.rewriteResumeLinksWithTracer.mockResolvedValue({
|
||||||
|
rewrittenLinks: 2,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should use provided selectedProjectIds and BYPASS AI selection", async () => {
|
it("should use provided selectedProjectIds and BYPASS AI selection", async () => {
|
||||||
@ -281,4 +299,27 @@ describe("PDF Service Tailoring Logic", () => {
|
|||||||
).length;
|
).length;
|
||||||
expect(visibleCount).toBe(1);
|
expect(visibleCount).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("does not rewrite links when tracer links are disabled", async () => {
|
||||||
|
await generatePdf("job-no-tracer", {}, "desc", undefined, undefined, {
|
||||||
|
tracerLinksEnabled: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockTracerLinks.resolveTracerPublicBaseUrl).not.toHaveBeenCalled();
|
||||||
|
expect(mockTracerLinks.rewriteResumeLinksWithTracer).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rewrites links when tracer links are enabled", async () => {
|
||||||
|
await generatePdf("job-with-tracer", {}, "desc", undefined, undefined, {
|
||||||
|
tracerLinksEnabled: true,
|
||||||
|
requestOrigin: "https://jobops.example",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockTracerLinks.resolveTracerPublicBaseUrl).toHaveBeenCalledWith({
|
||||||
|
requestOrigin: "https://jobops.example",
|
||||||
|
});
|
||||||
|
expect(mockTracerLinks.rewriteResumeLinksWithTracer).toHaveBeenCalledTimes(
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -17,6 +17,10 @@ import {
|
|||||||
resolveResumeProjectsSettings,
|
resolveResumeProjectsSettings,
|
||||||
} from "./resumeProjects";
|
} from "./resumeProjects";
|
||||||
import { RxResumeClient } from "./rxresume-client";
|
import { RxResumeClient } from "./rxresume-client";
|
||||||
|
import {
|
||||||
|
resolveTracerPublicBaseUrl,
|
||||||
|
rewriteResumeLinksWithTracer,
|
||||||
|
} from "./tracer-links";
|
||||||
|
|
||||||
const OUTPUT_DIR = join(getDataDir(), "pdfs");
|
const OUTPUT_DIR = join(getDataDir(), "pdfs");
|
||||||
|
|
||||||
@ -32,6 +36,12 @@ export interface TailoredPdfContent {
|
|||||||
skills?: Array<{ name: string; keywords: string[] }> | null;
|
skills?: Array<{ name: string; keywords: string[] }> | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface GeneratePdfOptions {
|
||||||
|
tracerLinksEnabled?: boolean;
|
||||||
|
requestOrigin?: string | null;
|
||||||
|
tracerCompanyName?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get RxResume credentials from environment variables or database settings.
|
* Get RxResume credentials from environment variables or database settings.
|
||||||
*/
|
*/
|
||||||
@ -104,6 +114,7 @@ export async function generatePdf(
|
|||||||
jobDescription: string,
|
jobDescription: string,
|
||||||
_baseResumePath?: string, // Deprecated: now always uses getProfile() which fetches from v4 API
|
_baseResumePath?: string, // Deprecated: now always uses getProfile() which fetches from v4 API
|
||||||
selectedProjectIds?: string | null,
|
selectedProjectIds?: string | null,
|
||||||
|
options?: GeneratePdfOptions,
|
||||||
): Promise<PdfResult> {
|
): Promise<PdfResult> {
|
||||||
console.log(`📄 Generating PDF for job ${jobId} using RxResume v4 API...`);
|
console.log(`📄 Generating PDF for job ${jobId} using RxResume v4 API...`);
|
||||||
|
|
||||||
@ -264,6 +275,24 @@ export async function generatePdf(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (options?.tracerLinksEnabled) {
|
||||||
|
const tracerBaseUrl = resolveTracerPublicBaseUrl({
|
||||||
|
requestOrigin: options.requestOrigin,
|
||||||
|
});
|
||||||
|
if (!tracerBaseUrl) {
|
||||||
|
throw new Error(
|
||||||
|
"Tracer links are enabled but no public base URL is available. Set JOBOPS_PUBLIC_BASE_URL.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await rewriteResumeLinksWithTracer({
|
||||||
|
jobId,
|
||||||
|
resumeData: baseResume,
|
||||||
|
publicBaseUrl: tracerBaseUrl,
|
||||||
|
companyName: options.tracerCompanyName ?? null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Use withAutoRefresh to handle token caching and 401 retry automatically
|
// Use withAutoRefresh to handle token caching and 401 retry automatically
|
||||||
const outputPath = join(OUTPUT_DIR, `resume_${jobId}.pdf`);
|
const outputPath = join(OUTPUT_DIR, `resume_${jobId}.pdf`);
|
||||||
|
|
||||||
|
|||||||
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
|
"Senior TypeScript Developer", // Original JD
|
||||||
undefined, // Deprecated profile path
|
undefined, // Deprecated profile path
|
||||||
"project-a,project-c", // The manually selected projects
|
"project-a,project-c", // The manually selected projects
|
||||||
|
expect.objectContaining({
|
||||||
|
requestOrigin: null,
|
||||||
|
tracerLinksEnabled: undefined,
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -86,6 +90,10 @@ describe("Tailoring Flow", () => {
|
|||||||
"Junior Java Developer",
|
"Junior Java Developer",
|
||||||
undefined, // Deprecated profile path
|
undefined, // Deprecated profile path
|
||||||
undefined, // No projects selected
|
undefined, // No projects selected
|
||||||
|
expect.objectContaining({
|
||||||
|
requestOrigin: null,
|
||||||
|
tracerLinksEnabled: undefined,
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -35,6 +35,7 @@ export const createJob = (overrides: Partial<Job> = {}): Job => ({
|
|||||||
tailoredSkills: null,
|
tailoredSkills: null,
|
||||||
selectedProjectIds: null,
|
selectedProjectIds: null,
|
||||||
pdfPath: null,
|
pdfPath: null,
|
||||||
|
tracerLinksEnabled: false,
|
||||||
sponsorMatchScore: null,
|
sponsorMatchScore: null,
|
||||||
sponsorMatchNames: null,
|
sponsorMatchNames: null,
|
||||||
jobType: null,
|
jobType: null,
|
||||||
|
|||||||
@ -162,6 +162,7 @@ export interface Job {
|
|||||||
tailoredSkills: string | null; // Generated resume skills (JSON)
|
tailoredSkills: string | null; // Generated resume skills (JSON)
|
||||||
selectedProjectIds: string | null; // Comma-separated IDs of selected projects
|
selectedProjectIds: string | null; // Comma-separated IDs of selected projects
|
||||||
pdfPath: string | null; // Path to generated PDF
|
pdfPath: string | null; // Path to generated PDF
|
||||||
|
tracerLinksEnabled: boolean; // Rewrite outbound resume links to tracer links on next PDF generation
|
||||||
sponsorMatchScore: number | null; // 0-100 fuzzy match score with visa sponsors
|
sponsorMatchScore: number | null; // 0-100 fuzzy match score with visa sponsors
|
||||||
sponsorMatchNames: string | null; // JSON array of matched sponsor names (when 100% matches or top match)
|
sponsorMatchNames: string | null; // JSON array of matched sponsor names (when 100% matches or top match)
|
||||||
|
|
||||||
@ -317,6 +318,7 @@ export interface UpdateJobInput {
|
|||||||
tailoredSkills?: string;
|
tailoredSkills?: string;
|
||||||
selectedProjectIds?: string;
|
selectedProjectIds?: string;
|
||||||
pdfPath?: string;
|
pdfPath?: string;
|
||||||
|
tracerLinksEnabled?: boolean;
|
||||||
appliedAt?: string;
|
appliedAt?: string;
|
||||||
sponsorMatchScore?: number;
|
sponsorMatchScore?: number;
|
||||||
sponsorMatchNames?: string;
|
sponsorMatchNames?: string;
|
||||||
@ -368,6 +370,105 @@ export type ApiResponse<T> =
|
|||||||
meta: ApiMeta;
|
meta: ApiMeta;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export interface TracerAnalyticsTimeseriesPoint {
|
||||||
|
day: string; // YYYY-MM-DD
|
||||||
|
clicks: number;
|
||||||
|
uniqueOpens: number;
|
||||||
|
botClicks: number;
|
||||||
|
humanClicks: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TracerAnalyticsTopJob {
|
||||||
|
jobId: string;
|
||||||
|
title: string;
|
||||||
|
employer: string;
|
||||||
|
clicks: number;
|
||||||
|
uniqueOpens: number;
|
||||||
|
botClicks: number;
|
||||||
|
humanClicks: number;
|
||||||
|
lastClickedAt: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TracerAnalyticsTopLink {
|
||||||
|
tracerLinkId: string;
|
||||||
|
token: string;
|
||||||
|
jobId: string;
|
||||||
|
title: string;
|
||||||
|
employer: string;
|
||||||
|
sourcePath: string;
|
||||||
|
sourceLabel: string;
|
||||||
|
destinationUrl: string;
|
||||||
|
clicks: number;
|
||||||
|
uniqueOpens: number;
|
||||||
|
botClicks: number;
|
||||||
|
humanClicks: number;
|
||||||
|
lastClickedAt: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TracerAnalyticsResponse {
|
||||||
|
filters: {
|
||||||
|
jobId: string | null;
|
||||||
|
from: number | null;
|
||||||
|
to: number | null;
|
||||||
|
includeBots: boolean;
|
||||||
|
limit: number;
|
||||||
|
};
|
||||||
|
totals: {
|
||||||
|
clicks: number;
|
||||||
|
uniqueOpens: number;
|
||||||
|
botClicks: number;
|
||||||
|
humanClicks: number;
|
||||||
|
};
|
||||||
|
timeSeries: TracerAnalyticsTimeseriesPoint[];
|
||||||
|
topJobs: TracerAnalyticsTopJob[];
|
||||||
|
topLinks: TracerAnalyticsTopLink[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface JobTracerLinkAnalyticsItem {
|
||||||
|
tracerLinkId: string;
|
||||||
|
token: string;
|
||||||
|
sourcePath: string;
|
||||||
|
sourceLabel: string;
|
||||||
|
destinationUrl: string;
|
||||||
|
isActive: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
clicks: number;
|
||||||
|
uniqueOpens: number;
|
||||||
|
botClicks: number;
|
||||||
|
humanClicks: number;
|
||||||
|
lastClickedAt: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface JobTracerLinksResponse {
|
||||||
|
job: {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
employer: string;
|
||||||
|
tracerLinksEnabled: boolean;
|
||||||
|
};
|
||||||
|
totals: {
|
||||||
|
links: number;
|
||||||
|
clicks: number;
|
||||||
|
uniqueOpens: number;
|
||||||
|
botClicks: number;
|
||||||
|
humanClicks: number;
|
||||||
|
};
|
||||||
|
links: JobTracerLinkAnalyticsItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TracerReadinessStatus = "ready" | "unconfigured" | "unavailable";
|
||||||
|
|
||||||
|
export interface TracerReadinessResponse {
|
||||||
|
status: TracerReadinessStatus;
|
||||||
|
canEnable: boolean;
|
||||||
|
publicBaseUrl: string | null;
|
||||||
|
healthUrl: string | null;
|
||||||
|
checkedAt: number;
|
||||||
|
lastSuccessAt: number | null;
|
||||||
|
reason: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export const POST_APPLICATION_PROVIDERS = ["gmail", "imap"] as const;
|
export const POST_APPLICATION_PROVIDERS = ["gmail", "imap"] as const;
|
||||||
export type PostApplicationProvider =
|
export type PostApplicationProvider =
|
||||||
(typeof POST_APPLICATION_PROVIDERS)[number];
|
(typeof POST_APPLICATION_PROVIDERS)[number];
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user