Deduplicate shared helpers and enforce aliased imports (#228)

* Deduplicate string cleanup helpers and not-found responses

* Enforce aliased imports for infra and shared modules

* Enforce @client/@server aliases for deep relative imports

* Deduplicate visa sponsor and location filter definitions

* Use shared city filter export in extractor location checks
This commit is contained in:
Shaheer Sarfaraz 2026-02-22 16:13:52 +00:00 committed by GitHub
parent 16acdf2b5e
commit 3da5ea35b4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
67 changed files with 385 additions and 346 deletions

View File

@ -17,6 +17,66 @@
"tailwindDirectives": true "tailwindDirectives": true
} }
}, },
"linter": {
"rules": {
"style": {
"noRestrictedImports": {
"level": "error",
"options": {
"patterns": [
{
"group": [
"../infra/**",
"../../infra/**",
"../../../infra/**",
"../../../../infra/**",
"../shared/**",
"../../shared/**",
"../../../shared/**",
"../../../../shared/**"
],
"message": "Use path aliases (for example @infra/* or @shared/*) instead of parent-relative imports for shared modules."
},
{
"group": [
"../../api",
"../../api/**",
"../../hooks",
"../../hooks/**",
"../../components",
"../../components/**",
"../../lib",
"../../lib/**",
"../../test",
"../../test/**"
],
"message": "Use @client/* aliases instead of ../../ imports for client modules."
},
{
"group": [
"../../services",
"../../services/**",
"../../repositories",
"../../repositories/**",
"../../config",
"../../config/**",
"../../utils",
"../../utils/**",
"../../pipeline",
"../../pipeline/**",
"../../db",
"../../db/**",
"../../extractors",
"../../extractors/**"
],
"message": "Use @server/* aliases instead of ../../ imports for server modules."
}
]
}
}
}
}
},
"overrides": [ "overrides": [
{ {
"includes": ["**/*.test.ts", "**/*.test.tsx", "**/test-utils.ts"], "includes": ["**/*.test.ts", "**/*.test.tsx", "**/test-utils.ts"],

View File

@ -63,13 +63,6 @@ export interface AdzunaResult {
error?: string; error?: string;
} }
export function shouldApplyStrictLocationFilter(
location: string,
countryKey: string,
): boolean {
return shouldApplyStrictCityFilter(location, countryKey);
}
function resolveTsxCliPath(): string | null { function resolveTsxCliPath(): string | null {
try { try {
return require.resolve("tsx/dist/cli.mjs"); return require.resolve("tsx/dist/cli.mjs");
@ -214,8 +207,7 @@ export async function runAdzuna(
for (let runIndex = 0; runIndex < runLocations.length; runIndex += 1) { for (let runIndex = 0; runIndex < runLocations.length; runIndex += 1) {
const location = runLocations[runIndex]; const location = runLocations[runIndex];
const strictLocationFilter = const strictLocationFilter =
location !== null && location !== null && shouldApplyStrictCityFilter(location, countryKey);
shouldApplyStrictLocationFilter(location, countryKey);
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
const extractorEnv = { const extractorEnv = {

View File

@ -1,15 +1,13 @@
import { shouldApplyStrictCityFilter } from "@shared/search-cities.js";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { shouldApplyStrictLocationFilter } from "../src/run";
describe("adzuna location query strictness", () => { describe("adzuna location query strictness", () => {
it("enables strict filtering when city differs from country", () => { it("enables strict filtering when city differs from country", () => {
expect(shouldApplyStrictLocationFilter("Leeds", "united kingdom")).toBe( expect(shouldApplyStrictCityFilter("Leeds", "united kingdom")).toBe(true);
true,
);
}); });
it("disables strict filtering when location is country-level", () => { it("disables strict filtering when location is country-level", () => {
expect(shouldApplyStrictLocationFilter("UK", "united kingdom")).toBe(false); expect(shouldApplyStrictCityFilter("UK", "united kingdom")).toBe(false);
expect(shouldApplyStrictLocationFilter("United States", "us")).toBe(false); expect(shouldApplyStrictCityFilter("United States", "us")).toBe(false);
}); });
}); });

View File

@ -65,13 +65,6 @@ export interface HiringCafeResult {
error?: string; error?: string;
} }
export function shouldApplyStrictLocationFilter(
location: string,
countryKey: string,
): boolean {
return shouldApplyStrictCityFilter(location, countryKey);
}
function resolveTsxCliPath(): string | null { function resolveTsxCliPath(): string | null {
try { try {
return require.resolve("tsx/dist/cli.mjs"); return require.resolve("tsx/dist/cli.mjs");
@ -213,8 +206,7 @@ export async function runHiringCafe(
for (let runIndex = 0; runIndex < runLocations.length; runIndex += 1) { for (let runIndex = 0; runIndex < runLocations.length; runIndex += 1) {
const location = runLocations[runIndex]; const location = runLocations[runIndex];
const strictLocationFilter = const strictLocationFilter =
location !== null && location !== null && shouldApplyStrictCityFilter(location, countryKey);
shouldApplyStrictLocationFilter(location, countryKey);
await clearStorageDataset(); await clearStorageDataset();

View File

@ -1,15 +1,13 @@
import { shouldApplyStrictCityFilter } from "@shared/search-cities.js";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { shouldApplyStrictLocationFilter } from "../src/run";
describe("hiringcafe location query strictness", () => { describe("hiringcafe location query strictness", () => {
it("enables strict filtering when city differs from country", () => { it("enables strict filtering when city differs from country", () => {
expect(shouldApplyStrictLocationFilter("Leeds", "united kingdom")).toBe( expect(shouldApplyStrictCityFilter("Leeds", "united kingdom")).toBe(true);
true,
);
}); });
it("disables strict filtering when location is country-level", () => { it("disables strict filtering when location is country-level", () => {
expect(shouldApplyStrictLocationFilter("UK", "united kingdom")).toBe(false); expect(shouldApplyStrictCityFilter("UK", "united kingdom")).toBe(false);
expect(shouldApplyStrictLocationFilter("United States", "us")).toBe(false); expect(shouldApplyStrictCityFilter("United States", "us")).toBe(false);
}); });
}); });

View File

@ -1,3 +1,5 @@
import * as api from "@client/api";
import { renderWithQueryClient } from "@client/test/renderWithQueryClient";
import { createJob } from "@shared/testing/factories.js"; import { createJob } from "@shared/testing/factories.js";
import type { Job } from "@shared/types.js"; import type { Job } from "@shared/types.js";
import { fireEvent, screen, waitFor } from "@testing-library/react"; import { fireEvent, screen, waitFor } from "@testing-library/react";
@ -5,8 +7,6 @@ import type React from "react";
import { MemoryRouter } from "react-router-dom"; 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 { renderWithQueryClient } from "../../test/renderWithQueryClient";
import { DiscoveredPanel } from "./DiscoveredPanel"; import { DiscoveredPanel } from "./DiscoveredPanel";
const render = (ui: Parameters<typeof renderWithQueryClient>[0]) => const render = (ui: Parameters<typeof renderWithQueryClient>[0]) =>
@ -44,11 +44,11 @@ vi.mock("@/components/ui/dropdown-menu", () => {
}; };
}); });
vi.mock("../../hooks/useSettings", () => ({ vi.mock("@client/hooks/useSettings", () => ({
useSettings: () => ({ showSponsorInfo: false }), useSettings: () => ({ showSponsorInfo: false }),
})); }));
vi.mock("../../api", () => ({ vi.mock("@client/api", () => ({
rescoreJob: vi.fn(), rescoreJob: vi.fn(),
skipJob: vi.fn(), skipJob: vi.fn(),
processJob: vi.fn(), processJob: vi.fn(),

View File

@ -1,10 +1,10 @@
import * as api from "@client/api";
import { useSkipJobMutation } from "@client/hooks/queries/useJobMutations";
import { useRescoreJob } from "@client/hooks/useRescoreJob";
import type { Job } from "@shared/types.js"; import type { Job } from "@shared/types.js";
import type React from "react"; import type React from "react";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import * as api from "../../api";
import { useSkipJobMutation } from "../../hooks/queries/useJobMutations";
import { useRescoreJob } from "../../hooks/useRescoreJob";
import { JobDetailsEditDrawer } from "../JobDetailsEditDrawer"; import { JobDetailsEditDrawer } from "../JobDetailsEditDrawer";
import { DecideMode } from "./DecideMode"; import { DecideMode } from "./DecideMode";
import { EmptyState } from "./EmptyState"; import { EmptyState } from "./EmptyState";

View File

@ -1,24 +1,24 @@
import * as api from "@client/api";
import { useProfile } from "@client/hooks/useProfile";
import { _resetTracerReadinessCache } from "@client/hooks/useTracerReadiness";
import { renderWithQueryClient } from "@client/test/renderWithQueryClient";
import { createJob as createBaseJob } from "@shared/testing/factories.js"; import { createJob as createBaseJob } from "@shared/testing/factories.js";
import type { Job } from "@shared/types.js"; import type { Job } from "@shared/types.js";
import { fireEvent, screen, waitFor } from "@testing-library/react"; import { fireEvent, 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 { useProfile } from "../../hooks/useProfile";
import { _resetTracerReadinessCache } from "../../hooks/useTracerReadiness";
import { renderWithQueryClient } from "../../test/renderWithQueryClient";
import { TailorMode } from "./TailorMode"; import { TailorMode } from "./TailorMode";
const render = (ui: Parameters<typeof renderWithQueryClient>[0]) => const render = (ui: Parameters<typeof renderWithQueryClient>[0]) =>
renderWithQueryClient(ui); renderWithQueryClient(ui);
vi.mock("../../api", () => ({ vi.mock("@client/api", () => ({
getResumeProjectsCatalog: vi.fn().mockResolvedValue([]), getResumeProjectsCatalog: vi.fn().mockResolvedValue([]),
updateJob: vi.fn(), updateJob: vi.fn(),
summarizeJob: vi.fn(), summarizeJob: vi.fn(),
getTracerReadiness: vi.fn(), getTracerReadiness: vi.fn(),
})); }));
vi.mock("../../hooks/useProfile", () => ({ vi.mock("@client/hooks/useProfile", () => ({
useProfile: vi.fn(), useProfile: vi.fn(),
})); }));

View File

@ -1,8 +1,8 @@
import * as api from "@client/api";
import type { Job, JobChatMessage, JobChatStreamEvent } from "@shared/types"; import type { Job, JobChatMessage, JobChatStreamEvent } from "@shared/types";
import type React from "react"; import type React from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import * as api from "../../api";
import { Composer } from "./Composer"; import { Composer } from "./Composer";
import { MessageList } from "./MessageList"; import { MessageList } from "./MessageList";

View File

@ -1,3 +1,6 @@
import * as api from "@client/api";
import { useProfile } from "@client/hooks/useProfile";
import { useTracerReadiness } from "@client/hooks/useTracerReadiness";
import type { Job } from "@shared/types.js"; import type { Job } from "@shared/types.js";
import { ArrowLeft, Check, FileText, Loader2, Sparkles } from "lucide-react"; import { ArrowLeft, Check, FileText, Loader2, Sparkles } from "lucide-react";
import type React from "react"; import type React from "react";
@ -6,9 +9,6 @@ import { useCallback, 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 { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import * as api from "../../api";
import { useProfile } from "../../hooks/useProfile";
import { useTracerReadiness } from "../../hooks/useTracerReadiness";
import { import {
fromEditableSkillGroups, fromEditableSkillGroups,
getOriginalHeadline, getOriginalHeadline,

View File

@ -1,6 +1,6 @@
import * as api from "@client/api";
import type { Job, ResumeProjectCatalogItem } from "@shared/types.js"; import type { Job, ResumeProjectCatalogItem } from "@shared/types.js";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import * as api from "../../api";
import { import {
createTailoredSkillDraftId, createTailoredSkillDraftId,
type EditableSkillGroup, type EditableSkillGroup,

View File

@ -1,3 +1,4 @@
import { CollapsibleSection } from "@client/components/discovered-panel/CollapsibleSection";
import { import {
type ApplicationStage, type ApplicationStage,
STAGE_LABELS, STAGE_LABELS,
@ -18,7 +19,6 @@ import {
import React from "react"; import React from "react";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { cn, formatTimestamp, formatTimestampWithTime } from "@/lib/utils"; import { cn, formatTimestamp, formatTimestampWithTime } from "@/lib/utils";
import { CollapsibleSection } from "../../components/discovered-panel/CollapsibleSection";
const stageIcons: Record<ApplicationStage, React.ReactNode> = { const stageIcons: Record<ApplicationStage, React.ReactNode> = {
applied: <CheckCircle2 className="h-4 w-4" />, applied: <CheckCircle2 className="h-4 w-4" />,

View File

@ -1,10 +1,10 @@
import * as api from "@client/api";
import { renderWithQueryClient } from "@client/test/renderWithQueryClient";
import { createJob } from "@shared/testing/factories.js"; import { createJob } from "@shared/testing/factories.js";
import type { Job } from "@shared/types.js"; import type { Job } from "@shared/types.js";
import { act, fireEvent, screen, waitFor } from "@testing-library/react"; import { act, fireEvent, 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 { renderWithQueryClient } from "../../test/renderWithQueryClient";
import { JobDetailPanel } from "./JobDetailPanel"; import { JobDetailPanel } from "./JobDetailPanel";
const render = (ui: Parameters<typeof renderWithQueryClient>[0]) => const render = (ui: Parameters<typeof renderWithQueryClient>[0]) =>
@ -42,7 +42,7 @@ vi.mock("@/components/ui/dropdown-menu", () => {
}; };
}); });
vi.mock("../../components", () => ({ vi.mock("@client/components", () => ({
DiscoveredPanel: ({ job }: { job: Job | null }) => ( DiscoveredPanel: ({ job }: { job: Job | null }) => (
<div data-testid="discovered-panel">{job?.id ?? "no-job"}</div> <div data-testid="discovered-panel">{job?.id ?? "no-job"}</div>
), ),
@ -51,7 +51,7 @@ vi.mock("../../components", () => ({
TailoredSummary: () => <div data-testid="tailored-summary" />, TailoredSummary: () => <div data-testid="tailored-summary" />,
})); }));
vi.mock("../../components/ReadyPanel", () => ({ vi.mock("@client/components/ReadyPanel", () => ({
ReadyPanel: ({ onEditDescription }: { onEditDescription?: () => void }) => ( ReadyPanel: ({ onEditDescription }: { onEditDescription?: () => void }) => (
<div> <div>
<div data-testid="ready-panel" /> <div data-testid="ready-panel" />
@ -62,7 +62,7 @@ vi.mock("../../components/ReadyPanel", () => ({
), ),
})); }));
vi.mock("../../components/TailoringEditor", () => ({ vi.mock("@client/components/TailoringEditor", () => ({
TailoringEditor: ({ TailoringEditor: ({
onDirtyChange, onDirtyChange,
}: { }: {
@ -79,7 +79,7 @@ vi.mock("../../components/TailoringEditor", () => ({
), ),
})); }));
vi.mock("../../components/JobDetailsEditDrawer", () => ({ vi.mock("@client/components/JobDetailsEditDrawer", () => ({
JobDetailsEditDrawer: ({ JobDetailsEditDrawer: ({
open, open,
onOpenChange, onOpenChange,
@ -116,7 +116,7 @@ vi.mock("@/lib/utils", async (importOriginal) => {
}; };
}); });
vi.mock("../../api", () => ({ vi.mock("@client/api", () => ({
updateJob: vi.fn(), updateJob: vi.fn(),
processJob: vi.fn(), processJob: vi.fn(),
generateJobPdf: vi.fn(), generateJobPdf: vi.fn(),

View File

@ -1,3 +1,18 @@
import * as api from "@client/api";
import {
DiscoveredPanel,
FitAssessment,
JobHeader,
TailoredSummary,
} from "@client/components";
import { JobDetailsEditDrawer } from "@client/components/JobDetailsEditDrawer";
import { ReadyPanel } from "@client/components/ReadyPanel";
import { TailoringEditor } from "@client/components/TailoringEditor";
import {
useMarkAsAppliedMutation,
useSkipJobMutation,
} from "@client/hooks/queries/useJobMutations";
import { useProfile } from "@client/hooks/useProfile";
import type { Job, JobListItem } from "@shared/types.js"; import type { Job, JobListItem } from "@shared/types.js";
import { import {
CheckCircle2, CheckCircle2,
@ -32,21 +47,6 @@ import {
safeFilenamePart, safeFilenamePart,
stripHtml, stripHtml,
} from "@/lib/utils"; } from "@/lib/utils";
import * as api from "../../api";
import {
DiscoveredPanel,
FitAssessment,
JobHeader,
TailoredSummary,
} from "../../components";
import { JobDetailsEditDrawer } from "../../components/JobDetailsEditDrawer";
import { ReadyPanel } from "../../components/ReadyPanel";
import { TailoringEditor } from "../../components/TailoringEditor";
import {
useMarkAsAppliedMutation,
useSkipJobMutation,
} from "../../hooks/queries/useJobMutations";
import { useProfile } from "../../hooks/useProfile";
import type { FilterTab } from "./constants"; import type { FilterTab } from "./constants";
interface JobDetailPanelProps { interface JobDetailPanelProps {

View File

@ -1,6 +1,6 @@
import { PipelineProgress } from "@client/components";
import type { JobStatus } from "@shared/types.js"; import type { JobStatus } from "@shared/types.js";
import type React from "react"; import type React from "react";
import { PipelineProgress } from "../../components";
interface OrchestratorSummaryProps { interface OrchestratorSummaryProps {
stats: Record<JobStatus, number>; stats: Record<JobStatus, number>;

View File

@ -1,12 +1,12 @@
import * as api from "@client/api";
import { createJob } from "@shared/testing/factories.js"; import { createJob } from "@shared/testing/factories.js";
import type { JobActionResponse, JobActionStreamEvent } from "@shared/types.js"; import type { JobActionResponse, JobActionStreamEvent } from "@shared/types.js";
import { act, renderHook, waitFor } from "@testing-library/react"; import { act, renderHook, waitFor } from "@testing-library/react";
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 { useJobSelectionActions } from "./useJobSelectionActions"; import { useJobSelectionActions } from "./useJobSelectionActions";
vi.mock("../../api", () => ({ vi.mock("@client/api", () => ({
streamJobAction: vi.fn(), streamJobAction: vi.fn(),
})); }));

View File

@ -1,3 +1,4 @@
import * as api from "@client/api";
import type { import type {
JobAction, JobAction,
JobActionResponse, JobActionResponse,
@ -5,7 +6,6 @@ import type {
} from "@shared/types.js"; } from "@shared/types.js";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import * as api from "../../api";
import type { FilterTab } from "./constants"; import type { FilterTab } from "./constants";
import { JobActionProgressToast } from "./JobActionProgressToast"; import { JobActionProgressToast } from "./JobActionProgressToast";
import { import {

View File

@ -1,13 +1,13 @@
import * as api from "@client/api";
import { renderHookWithQueryClient } from "@client/test/renderWithQueryClient";
import { act, waitFor } from "@testing-library/react"; import { act, 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 { renderHookWithQueryClient } from "../../test/renderWithQueryClient";
import { useOrchestratorData } from "./useOrchestratorData"; import { useOrchestratorData } from "./useOrchestratorData";
const renderHook = (callback: () => ReturnType<typeof useOrchestratorData>) => const renderHook = (callback: () => ReturnType<typeof useOrchestratorData>) =>
renderHookWithQueryClient(callback); renderHookWithQueryClient(callback);
vi.mock("../../api", () => ({ vi.mock("@client/api", () => ({
getJobs: vi.fn(), getJobs: vi.fn(),
getJobsRevision: vi.fn(), getJobsRevision: vi.fn(),
getJob: vi.fn(), getJob: vi.fn(),

View File

@ -1,10 +1,10 @@
import * as api from "@client/api";
import { subscribeToEventSource } from "@client/lib/sse";
import type { Job, JobListItem, JobStatus } from "@shared/types"; import type { Job, JobListItem, JobStatus } from "@shared/types";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { queryKeys } from "@/client/lib/queryKeys"; import { queryKeys } from "@/client/lib/queryKeys";
import * as api from "../../api";
import { subscribeToEventSource } from "../../lib/sse";
const initialStats: Record<JobStatus, number> = { const initialStats: Record<JobStatus, number> = {
discovered: 0, discovered: 0,

View File

@ -3,6 +3,7 @@ import {
sourceLabel as getExtractorSourceLabel, sourceLabel as getExtractorSourceLabel,
} from "@shared/extractors"; } from "@shared/extractors";
import type { Job } from "@shared/types"; import type { Job } from "@shared/types";
import { stripHtmlTags } from "@shared/utils/string";
import { type ClassValue, clsx } from "clsx"; import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
@ -101,11 +102,7 @@ export async function copyTextToClipboard(text: string) {
} }
// --- Text Processing --- // --- Text Processing ---
export const stripHtml = (value: string) => export const stripHtml = (value: string) => stripHtmlTags(value);
value
.replace(/<[^>]*>/g, " ")
.replace(/\s+/g, " ")
.trim();
export const safeFilenamePart = (value: string) => { export const safeFilenamePart = (value: string) => {
const cleaned = value.replace(/[^a-z0-9]/gi, "_"); const cleaned = value.replace(/[^a-z0-9]/gi, "_");

View File

@ -1,4 +1,7 @@
import { notFound } from "@infra/errors";
import { fail } from "@infra/http";
import { logger } from "@infra/logger"; import { logger } from "@infra/logger";
import { isDemoMode, sendDemoBlocked } from "@server/config/demo";
import { import {
createBackup, createBackup,
deleteBackup, deleteBackup,
@ -6,7 +9,6 @@ import {
listBackups, listBackups,
} from "@server/services/backup/index"; } from "@server/services/backup/index";
import { type Request, type Response, Router } from "express"; import { type Request, type Response, Router } from "express";
import { isDemoMode, sendDemoBlocked } from "../../config/demo";
export const backupRouter = Router(); export const backupRouter = Router();
@ -104,7 +106,7 @@ backupRouter.delete("/:filename", async (req: Request, res: Response) => {
}); });
if (message.includes("not found")) { if (message.includes("not found")) {
res.status(404).json({ success: false, error: message }); return fail(res, notFound(message));
} else if (message.includes("Invalid")) { } else if (message.includes("Invalid")) {
res.status(400).json({ success: false, error: message }); res.status(400).json({ success: false, error: message });
} else { } else {

View File

@ -17,7 +17,7 @@ describe.sequential("Database API routes", () => {
}); });
it("clears jobs and pipeline runs", async () => { it("clears jobs and pipeline runs", async () => {
const { createJob } = await import("../../repositories/jobs"); const { createJob } = await import("@server/repositories/jobs");
await createJob({ await createJob({
source: "manual", source: "manual",
title: "Cleanup Role", title: "Cleanup Role",

View File

@ -1,6 +1,6 @@
import { isDemoMode, sendDemoBlocked } from "@server/config/demo";
import { clearDatabase } from "@server/db/clear";
import { type Request, type Response, Router } from "express"; import { type Request, type Response, Router } from "express";
import { isDemoMode, sendDemoBlocked } from "../../config/demo";
import { clearDatabase } from "../../db/clear";
export const databaseRouter = Router(); export const databaseRouter = Router();

View File

@ -1,6 +1,6 @@
import { ok } from "@infra/http"; import { ok } from "@infra/http";
import { getDemoInfo } from "@server/config/demo";
import { type Request, type Response, Router } from "express"; import { type Request, type Response, Router } from "express";
import { getDemoInfo } from "../../config/demo";
export const demoRouter = Router(); export const demoRouter = Router();

View File

@ -2,7 +2,7 @@ import type { Server } from "node:http";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { startServer, stopServer } from "./test-utils"; import { startServer, stopServer } from "./test-utils";
vi.mock("../../services/ghostwriter", () => ({ vi.mock("@server/services/ghostwriter", () => ({
listThreads: vi.fn(async () => [ listThreads: vi.fn(async () => [
{ {
id: "thread-1", id: "thread-1",

View File

@ -2,9 +2,9 @@ import { asyncRoute, fail, ok } from "@infra/http";
import { runWithRequestContext } from "@infra/request-context"; import { runWithRequestContext } from "@infra/request-context";
import { setupSse, writeSseData } from "@infra/sse"; import { setupSse, writeSseData } from "@infra/sse";
import { badRequest, toAppError } from "@server/infra/errors"; import { badRequest, toAppError } from "@server/infra/errors";
import * as ghostwriterService from "@server/services/ghostwriter";
import { type Request, Router } from "express"; import { type Request, Router } from "express";
import { z } from "zod"; import { z } from "zod";
import * as ghostwriterService from "../../services/ghostwriter";
export const ghostwriterRouter = Router({ mergeParams: true }); export const ghostwriterRouter = Router({ mergeParams: true });

View File

@ -17,7 +17,7 @@ describe.sequential("Jobs API routes", () => {
}); });
it("lists jobs and supports status filtering", async () => { it("lists jobs and supports status filtering", async () => {
const { createJob } = await import("../../repositories/jobs"); const { createJob } = await import("@server/repositories/jobs");
const job = await createJob({ const job = await createJob({
source: "manual", source: "manual",
title: "Test Role", title: "Test Role",
@ -40,7 +40,7 @@ describe.sequential("Jobs API routes", () => {
}); });
it("supports lightweight and full jobs list views", async () => { it("supports lightweight and full jobs list views", async () => {
const { createJob } = await import("../../repositories/jobs"); const { createJob } = await import("@server/repositories/jobs");
await createJob({ await createJob({
source: "manual", source: "manual",
title: "List View Role", title: "List View Role",
@ -76,7 +76,7 @@ describe.sequential("Jobs API routes", () => {
}); });
it("returns jobs revision and supports status filtering", async () => { it("returns jobs revision and supports status filtering", async () => {
const { createJob, updateJob } = await import("../../repositories/jobs"); const { createJob, updateJob } = await import("@server/repositories/jobs");
const readyJob = await createJob({ const readyJob = await createJob({
source: "manual", source: "manual",
title: "Ready Role", title: "Ready Role",
@ -133,7 +133,7 @@ describe.sequential("Jobs API routes", () => {
}); });
it("updates core job detail fields", async () => { it("updates core job detail fields", async () => {
const { createJob } = await import("../../repositories/jobs"); const { createJob } = await import("@server/repositories/jobs");
const job = await createJob({ const job = await createJob({
source: "manual", source: "manual",
title: "Original Title", title: "Original Title",
@ -176,7 +176,7 @@ describe.sequential("Jobs API routes", () => {
}); });
it("blocks enabling tracer links when readiness check fails", async () => { it("blocks enabling tracer links when readiness check fails", async () => {
const { createJob } = await import("../../repositories/jobs"); const { createJob } = await import("@server/repositories/jobs");
const job = await createJob({ const job = await createJob({
source: "manual", source: "manual",
title: "Tracer Blocked", title: "Tracer Blocked",
@ -221,8 +221,8 @@ describe.sequential("Jobs API routes", () => {
}); });
it("allows updates for already-enabled tracer links without re-gating", async () => { it("allows updates for already-enabled tracer links without re-gating", async () => {
const { createJob } = await import("../../repositories/jobs"); const { createJob } = await import("@server/repositories/jobs");
const { updateJob } = await import("../../repositories/jobs"); const { updateJob } = await import("@server/repositories/jobs");
const job = await createJob({ const job = await createJob({
source: "manual", source: "manual",
title: "Tracer Already On", title: "Tracer Already On",
@ -288,8 +288,8 @@ describe.sequential("Jobs API routes", () => {
}); });
it("prefers JOBOPS_PUBLIC_BASE_URL over forwarded headers for generate-pdf origin", async () => { it("prefers JOBOPS_PUBLIC_BASE_URL over forwarded headers for generate-pdf origin", async () => {
const { createJob } = await import("../../repositories/jobs"); const { createJob } = await import("@server/repositories/jobs");
const { generateFinalPdf } = await import("../../pipeline/index"); const { generateFinalPdf } = await import("@server/pipeline/index");
const job = await createJob({ const job = await createJob({
source: "manual", source: "manual",
title: "Origin Test", title: "Origin Test",
@ -324,7 +324,7 @@ describe.sequential("Jobs API routes", () => {
}); });
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("@server/repositories/jobs");
const first = await createJob({ const first = await createJob({
source: "manual", source: "manual",
title: "First", title: "First",
@ -354,7 +354,7 @@ describe.sequential("Jobs API routes", () => {
}); });
it("validates job updates and supports skip/delete flow", async () => { it("validates job updates and supports skip/delete flow", async () => {
const { createJob } = await import("../../repositories/jobs"); const { createJob } = await import("@server/repositories/jobs");
const job = await createJob({ const job = await createJob({
source: "manual", source: "manual",
title: "Test Role", title: "Test Role",
@ -414,7 +414,7 @@ describe.sequential("Jobs API routes", () => {
}); });
it("runs skip action with partial failures", async () => { it("runs skip action with partial failures", async () => {
const { createJob } = await import("../../repositories/jobs"); const { createJob } = await import("@server/repositories/jobs");
const discovered = await createJob({ const discovered = await createJob({
source: "manual", source: "manual",
title: "Discovered Role", title: "Discovered Role",
@ -436,7 +436,7 @@ describe.sequential("Jobs API routes", () => {
jobUrl: "https://example.com/job/action-applied", jobUrl: "https://example.com/job/action-applied",
jobDescription: "Test description", jobDescription: "Test description",
}); });
const { updateJob } = await import("../../repositories/jobs"); const { updateJob } = await import("@server/repositories/jobs");
await updateJob(ready.id, { status: "ready" }); await updateJob(ready.id, { status: "ready" });
await updateJob(applied.id, { status: "applied" }); await updateJob(applied.id, { status: "applied" });
@ -465,7 +465,7 @@ describe.sequential("Jobs API routes", () => {
}); });
it("runs move_to_ready action and rejects ineligible statuses", async () => { it("runs move_to_ready action and rejects ineligible statuses", async () => {
const { createJob, updateJob } = await import("../../repositories/jobs"); const { createJob, updateJob } = await import("@server/repositories/jobs");
const discovered = await createJob({ const discovered = await createJob({
source: "manual", source: "manual",
title: "New Role", title: "New Role",
@ -481,7 +481,7 @@ describe.sequential("Jobs API routes", () => {
jobDescription: "Test description", jobDescription: "Test description",
}); });
await updateJob(ready.id, { status: "ready" }); await updateJob(ready.id, { status: "ready" });
const { processJob } = await import("../../pipeline/index"); const { processJob } = await import("@server/pipeline/index");
const previousBaseUrl = process.env.JOBOPS_PUBLIC_BASE_URL; const previousBaseUrl = process.env.JOBOPS_PUBLIC_BASE_URL;
process.env.JOBOPS_PUBLIC_BASE_URL = "https://canonical.jobops.example"; process.env.JOBOPS_PUBLIC_BASE_URL = "https://canonical.jobops.example";
@ -516,8 +516,8 @@ describe.sequential("Jobs API routes", () => {
}); });
it("supports legacy move_to_ready endpoint", async () => { it("supports legacy move_to_ready endpoint", async () => {
const { createJob } = await import("../../repositories/jobs"); const { createJob } = await import("@server/repositories/jobs");
const { processJob } = await import("../../pipeline/index"); const { processJob } = await import("@server/pipeline/index");
const job = await createJob({ const job = await createJob({
source: "manual", source: "manual",
title: "Legacy Ready Route", title: "Legacy Ready Route",
@ -550,9 +550,9 @@ describe.sequential("Jobs API routes", () => {
}); });
it("runs rescore action with partial failures", async () => { it("runs rescore action with partial failures", async () => {
const { createJob, updateJob } = await import("../../repositories/jobs"); const { createJob, updateJob } = await import("@server/repositories/jobs");
const { scoreJobSuitability } = await import("../../services/scorer"); const { scoreJobSuitability } = await import("@server/services/scorer");
const { getProfile } = await import("../../services/profile"); const { getProfile } = await import("@server/services/profile");
vi.mocked(getProfile).mockResolvedValue({}); vi.mocked(getProfile).mockResolvedValue({});
vi.mocked(scoreJobSuitability).mockResolvedValue({ vi.mocked(scoreJobSuitability).mockResolvedValue({
@ -618,7 +618,7 @@ describe.sequential("Jobs API routes", () => {
}); });
it("streams job action progress with done counters", async () => { it("streams job action progress with done counters", async () => {
const { createJob, updateJob } = await import("../../repositories/jobs"); const { createJob, updateJob } = await import("@server/repositories/jobs");
const discovered = await createJob({ const discovered = await createJob({
source: "manual", source: "manual",
title: "Discovered Role", title: "Discovered Role",
@ -727,7 +727,7 @@ describe.sequential("Jobs API routes", () => {
}); });
it("applies a job", async () => { it("applies a job", async () => {
const { createJob } = await import("../../repositories/jobs"); const { createJob } = await import("@server/repositories/jobs");
const job = await createJob({ const job = await createJob({
source: "manual", source: "manual",
title: "Test Role", title: "Test Role",
@ -746,9 +746,9 @@ describe.sequential("Jobs API routes", () => {
}); });
it("rescoring a job updates the suitability fields", async () => { it("rescoring a job updates the suitability fields", async () => {
const { createJob } = await import("../../repositories/jobs"); const { createJob } = await import("@server/repositories/jobs");
const { scoreJobSuitability } = await import("../../services/scorer"); const { scoreJobSuitability } = await import("@server/services/scorer");
const { getProfile } = await import("../../services/profile"); const { getProfile } = await import("@server/services/profile");
vi.mocked(getProfile).mockResolvedValue({}); vi.mocked(getProfile).mockResolvedValue({});
vi.mocked(scoreJobSuitability).mockResolvedValue({ vi.mocked(scoreJobSuitability).mockResolvedValue({
@ -764,7 +764,7 @@ describe.sequential("Jobs API routes", () => {
jobDescription: "Test description", jobDescription: "Test description",
}); });
const { updateJob } = await import("../../repositories/jobs"); const { updateJob } = await import("@server/repositories/jobs");
await updateJob(job.id, { await updateJob(job.id, {
suitabilityScore: 55, suitabilityScore: 55,
suitabilityReason: "Old fit", suitabilityReason: "Old fit",
@ -785,7 +785,7 @@ describe.sequential("Jobs API routes", () => {
}); });
it("deletes jobs below a score threshold (excluding applied)", async () => { it("deletes jobs below a score threshold (excluding applied)", async () => {
const { createJob, updateJob } = await import("../../repositories/jobs"); const { createJob, updateJob } = await import("@server/repositories/jobs");
// Create jobs with different scores and statuses // Create jobs with different scores and statuses
const lowScoreJob = await createJob({ const lowScoreJob = await createJob({
@ -883,7 +883,7 @@ describe.sequential("Jobs API routes", () => {
it("checks visa sponsor status for a job", async () => { it("checks visa sponsor status for a job", async () => {
const { searchSponsors } = await import( const { searchSponsors } = await import(
"../../services/visa-sponsors/index" "@server/services/visa-sponsors/index"
); );
vi.mocked(searchSponsors).mockReturnValue([ vi.mocked(searchSponsors).mockReturnValue([
{ {
@ -893,7 +893,7 @@ describe.sequential("Jobs API routes", () => {
}, },
]); ]);
const { createJob } = await import("../../repositories/jobs"); const { createJob } = await import("@server/repositories/jobs");
const job = await createJob({ const job = await createJob({
source: "manual", source: "manual",
title: "Sponsored Dev", title: "Sponsored Dev",
@ -915,7 +915,7 @@ describe.sequential("Jobs API routes", () => {
let jobId: string; let jobId: string;
beforeEach(async () => { beforeEach(async () => {
const { createJob } = await import("../../repositories/jobs"); const { createJob } = await import("@server/repositories/jobs");
const job = await createJob({ const job = await createJob({
source: "manual", source: "manual",
title: "Tracking Test", title: "Tracking Test",
@ -986,7 +986,7 @@ describe.sequential("Jobs API routes", () => {
}); });
it("manages application tasks", async () => { it("manages application tasks", async () => {
const { db, schema } = await import("../../db/index"); const { db, schema } = await import("@server/db/index");
const { eq } = await import("drizzle-orm"); const { eq } = await import("drizzle-orm");
const { tasks } = schema; const { tasks } = schema;

View File

@ -1,7 +1,42 @@
import {
AppError,
type AppErrorCode,
badRequest,
conflict,
notFound,
} from "@infra/errors";
import { fail, ok, okWithMeta } from "@infra/http"; import { fail, ok, okWithMeta } from "@infra/http";
import { logger } from "@infra/logger"; import { logger } from "@infra/logger";
import { sanitizeWebhookPayload } from "@infra/sanitize"; import { sanitizeWebhookPayload } from "@infra/sanitize";
import { setupSse, startSseHeartbeat, writeSseData } from "@infra/sse"; import { setupSse, startSseHeartbeat, writeSseData } from "@infra/sse";
import { isDemoMode, sendDemoBlocked } from "@server/config/demo";
import {
generateFinalPdf,
processJob,
summarizeJob,
} from "@server/pipeline/index";
import * as jobsRepo from "@server/repositories/jobs";
import * as settingsRepo from "@server/repositories/settings";
import {
deleteStageEvent,
getStageEvents,
getTasks,
stageEventMetadataSchema,
transitionStage,
updateStageEvent,
} from "@server/services/applicationTracking";
import {
simulateApplyJob,
simulateGeneratePdf,
simulateProcessJob,
simulateRescoreJob,
simulateSummarizeJob,
} from "@server/services/demo-simulator";
import { getProfile } from "@server/services/profile";
import { scoreJobSuitability } from "@server/services/scorer";
import { getTracerReadiness } from "@server/services/tracer-links";
import * as visaSponsors from "@server/services/visa-sponsors/index";
import { asyncPool } from "@server/utils/async-pool";
import { import {
APPLICATION_OUTCOMES, APPLICATION_OUTCOMES,
APPLICATION_STAGES, APPLICATION_STAGES,
@ -17,40 +52,6 @@ import {
} from "@shared/types"; } from "@shared/types";
import { type Request, type Response, Router } from "express"; import { type Request, type Response, Router } from "express";
import { z } from "zod"; import { z } from "zod";
import { isDemoMode, sendDemoBlocked } from "../../config/demo";
import {
AppError,
type AppErrorCode,
badRequest,
conflict,
} from "../../infra/errors";
import {
generateFinalPdf,
processJob,
summarizeJob,
} from "../../pipeline/index";
import * as jobsRepo from "../../repositories/jobs";
import * as settingsRepo from "../../repositories/settings";
import {
deleteStageEvent,
getStageEvents,
getTasks,
stageEventMetadataSchema,
transitionStage,
updateStageEvent,
} from "../../services/applicationTracking";
import {
simulateApplyJob,
simulateGeneratePdf,
simulateProcessJob,
simulateRescoreJob,
simulateSummarizeJob,
} from "../../services/demo-simulator";
import { getProfile } from "../../services/profile";
import { scoreJobSuitability } from "../../services/scorer";
import { getTracerReadiness } from "../../services/tracer-links";
import * as visaSponsors from "../../services/visa-sponsors/index";
import { asyncPool } from "../../utils/async-pool";
export const jobsRouter = Router(); export const jobsRouter = Router();
const JOB_ACTION_CONCURRENCY = 4; const JOB_ACTION_CONCURRENCY = 4;
@ -884,7 +885,7 @@ jobsRouter.get("/:id", async (req: Request, res: Response) => {
try { try {
const job = await jobsRepo.getJobById(req.params.id); const job = await jobsRepo.getJobById(req.params.id);
if (!job) { if (!job) {
return res.status(404).json({ success: false, error: "Job not found" }); return fail(res, notFound("Job not found"));
} }
res.json({ success: true, data: job }); res.json({ success: true, data: job });
} catch (error) { } catch (error) {
@ -996,7 +997,7 @@ jobsRouter.patch("/:id/outcome", async (req: Request, res: Response) => {
}); });
if (!job) { if (!job) {
return res.status(404).json({ success: false, error: "Job not found" }); return fail(res, notFound("Job not found"));
} }
res.json({ success: true, data: job }); res.json({ success: true, data: job });
@ -1126,7 +1127,7 @@ jobsRouter.post("/:id/summarize", async (req: Request, res: Response) => {
} }
const job = await jobsRepo.getJobById(req.params.id); const job = await jobsRepo.getJobById(req.params.id);
if (!job) { if (!job) {
return res.status(404).json({ success: false, error: "Job not found" }); return fail(res, notFound("Job not found"));
} }
return okWithMeta(res, job, { simulated: true }); return okWithMeta(res, job, { simulated: true });
} }
@ -1153,7 +1154,7 @@ jobsRouter.post("/:id/check-sponsor", async (req: Request, res: Response) => {
const job = await jobsRepo.getJobById(req.params.id); const job = await jobsRepo.getJobById(req.params.id);
if (!job) { if (!job) {
return res.status(404).json({ success: false, error: "Job not found" }); return fail(res, notFound("Job not found"));
} }
if (!job.employer) { if (!job.employer) {
@ -1203,7 +1204,7 @@ jobsRouter.post("/:id/generate-pdf", async (req: Request, res: Response) => {
} }
const job = await jobsRepo.getJobById(req.params.id); const job = await jobsRepo.getJobById(req.params.id);
if (!job) { if (!job) {
return res.status(404).json({ success: false, error: "Job not found" }); return fail(res, notFound("Job not found"));
} }
return okWithMeta(res, job, { simulated: true }); return okWithMeta(res, job, { simulated: true });
} }
@ -1237,7 +1238,7 @@ jobsRouter.post("/:id/apply", async (req: Request, res: Response) => {
const job = await jobsRepo.getJobById(req.params.id); const job = await jobsRepo.getJobById(req.params.id);
if (!job) { if (!job) {
return res.status(404).json({ success: false, error: "Job not found" }); return fail(res, notFound("Job not found"));
} }
const appliedAtDate = new Date(); const appliedAtDate = new Date();
@ -1266,7 +1267,7 @@ jobsRouter.post("/:id/apply", async (req: Request, res: Response) => {
} }
if (!updatedJob) { if (!updatedJob) {
return res.status(404).json({ success: false, error: "Job not found" }); return fail(res, notFound("Job not found"));
} }
res.json({ success: true, data: updatedJob }); res.json({ success: true, data: updatedJob });

View File

@ -46,7 +46,9 @@ describe.sequential("Manual jobs API routes", () => {
}); });
expect(badRes.status).toBe(400); expect(badRes.status).toBe(400);
const { inferManualJobDetails } = await import("../../services/manualJob"); const { inferManualJobDetails } = await import(
"@server/services/manualJob"
);
vi.mocked(inferManualJobDetails).mockResolvedValue({ vi.mocked(inferManualJobDetails).mockResolvedValue({
job: { title: "Backend Engineer", employer: "Acme" }, job: { title: "Backend Engineer", employer: "Acme" },
warning: null, warning: null,
@ -63,8 +65,8 @@ describe.sequential("Manual jobs API routes", () => {
}); });
it("imports manual jobs and generates a fallback URL", async () => { it("imports manual jobs and generates a fallback URL", async () => {
const { processJob } = await import("../../pipeline/index"); const { processJob } = await import("@server/pipeline/index");
const { scoreJobSuitability } = await import("../../services/scorer"); const { scoreJobSuitability } = await import("@server/services/scorer");
vi.mocked(scoreJobSuitability).mockResolvedValue({ vi.mocked(scoreJobSuitability).mockResolvedValue({
score: 88, score: 88,
reason: "Strong fit", reason: "Strong fit",

View File

@ -1,5 +1,12 @@
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { notFound } from "@infra/errors";
import { fail } from "@infra/http";
import { logger } from "@infra/logger"; import { logger } from "@infra/logger";
import { processJob } from "@server/pipeline/index";
import * as jobsRepo from "@server/repositories/jobs";
import { inferManualJobDetails } from "@server/services/manualJob";
import { getProfile } from "@server/services/profile";
import { scoreJobSuitability } from "@server/services/scorer";
import type { import type {
ApiResponse, ApiResponse,
ManualJobFetchResponse, ManualJobFetchResponse,
@ -8,11 +15,6 @@ import type {
import { type Request, type Response, Router } from "express"; import { type Request, type Response, Router } from "express";
import { JSDOM } from "jsdom"; import { JSDOM } from "jsdom";
import { z } from "zod"; import { z } from "zod";
import { processJob } from "../../pipeline/index";
import * as jobsRepo from "../../repositories/jobs";
import { inferManualJobDetails } from "../../services/manualJob";
import { getProfile } from "../../services/profile";
import { scoreJobSuitability } from "../../services/scorer";
export const manualJobsRouter = Router(); export const manualJobsRouter = Router();
@ -252,7 +254,7 @@ manualJobsRouter.post("/import", async (req: Request, res: Response) => {
const processedJob = await jobsRepo.getJobById(createdJob.id); const processedJob = await jobsRepo.getJobById(createdJob.id);
if (!processedJob) { if (!processedJob) {
return res.status(404).json({ success: false, error: "Job not found" }); return fail(res, notFound("Job not found"));
} }
// Score asynchronously so the import returns immediately. // Score asynchronously so the import returns immediately.

View File

@ -1,5 +1,6 @@
import { okWithMeta } from "@infra/http"; import { okWithMeta } from "@infra/http";
import { logger } from "@infra/logger"; import { logger } from "@infra/logger";
import { isDemoMode } from "@server/config/demo";
import { getSetting } from "@server/repositories/settings"; import { getSetting } from "@server/repositories/settings";
import { LlmService } from "@server/services/llm/service"; import { LlmService } from "@server/services/llm/service";
import { RxResumeClient } from "@server/services/rxresume-client"; import { RxResumeClient } from "@server/services/rxresume-client";
@ -9,7 +10,6 @@ import {
} from "@server/services/rxresume-v4"; } from "@server/services/rxresume-v4";
import { resumeDataSchema } from "@shared/rxresume-schema"; import { resumeDataSchema } from "@shared/rxresume-schema";
import { type Request, type Response, Router } from "express"; import { type Request, type Response, Router } from "express";
import { isDemoMode } from "../../config/demo";
export const onboardingRouter = Router(); export const onboardingRouter = Router();

View File

@ -32,7 +32,7 @@ describe.sequential("Pipeline API routes", () => {
}); });
expect(badRun.status).toBe(400); expect(badRun.status).toBe(400);
const { runPipeline } = await import("../../pipeline/index"); const { runPipeline } = await import("@server/pipeline/index");
const runRes = await fetch(`${baseUrl}/api/pipeline/run`, { const runRes = await fetch(`${baseUrl}/api/pipeline/run`, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
@ -81,7 +81,7 @@ describe.sequential("Pipeline API routes", () => {
}); });
it("accepts cancellation when pipeline is running", async () => { it("accepts cancellation when pipeline is running", async () => {
const { requestPipelineCancel } = await import("../../pipeline/index"); const { requestPipelineCancel } = await import("@server/pipeline/index");
vi.mocked(requestPipelineCancel).mockReturnValue({ vi.mocked(requestPipelineCancel).mockReturnValue({
accepted: true, accepted: true,
pipelineRunId: "run-1", pipelineRunId: "run-1",

View File

@ -9,23 +9,23 @@ import { fail, ok, okWithMeta } from "@infra/http";
import { logger } from "@infra/logger"; import { logger } from "@infra/logger";
import { runWithRequestContext } from "@infra/request-context"; import { runWithRequestContext } from "@infra/request-context";
import { setupSse, startSseHeartbeat, writeSseData } from "@infra/sse"; import { setupSse, startSseHeartbeat, writeSseData } from "@infra/sse";
import { PIPELINE_EXTRACTOR_SOURCE_IDS } from "@shared/extractors"; import { isDemoMode } from "@server/config/demo";
import type { PipelineStatusResponse } from "@shared/types";
import { type Request, type Response, Router } from "express";
import { z } from "zod";
import { isDemoMode } from "../../config/demo";
import { import {
type ExtractorRegistry, type ExtractorRegistry,
getExtractorRegistry, getExtractorRegistry,
} from "../../extractors/registry"; } from "@server/extractors/registry";
import { import {
getPipelineStatus, getPipelineStatus,
requestPipelineCancel, requestPipelineCancel,
runPipeline, runPipeline,
subscribeToProgress, subscribeToProgress,
} from "../../pipeline/index"; } from "@server/pipeline/index";
import * as pipelineRepo from "../../repositories/pipeline"; import * as pipelineRepo from "@server/repositories/pipeline";
import { simulatePipelineRun } from "../../services/demo-simulator"; import { simulatePipelineRun } from "@server/services/demo-simulator";
import { PIPELINE_EXTRACTOR_SOURCE_IDS } from "@shared/extractors";
import type { PipelineStatusResponse } from "@shared/types";
import { type Request, type Response, Router } from "express";
import { z } from "zod";
export const pipelineRouter = Router(); export const pipelineRouter = Router();

View File

@ -2,7 +2,7 @@ import type { Server } from "node:http";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { startServer, stopServer } from "./test-utils"; import { startServer, stopServer } from "./test-utils";
vi.mock("../../services/post-application/providers", () => ({ vi.mock("@server/services/post-application/providers", () => ({
executePostApplicationProviderAction: vi.fn(), executePostApplicationProviderAction: vi.fn(),
})); }));
@ -36,7 +36,7 @@ describe.sequential("Post-Application Provider actions API", () => {
it("dispatches provider status action and returns unified success contract", async () => { it("dispatches provider status action and returns unified success contract", async () => {
const { executePostApplicationProviderAction } = await import( const { executePostApplicationProviderAction } = await import(
"../../services/post-application/providers" "@server/services/post-application/providers"
); );
vi.mocked(executePostApplicationProviderAction).mockResolvedValueOnce({ vi.mocked(executePostApplicationProviderAction).mockResolvedValueOnce({
provider: "gmail", provider: "gmail",
@ -92,7 +92,7 @@ describe.sequential("Post-Application Provider actions API", () => {
it("defaults to account key 'default' when omitted", async () => { it("defaults to account key 'default' when omitted", async () => {
const { executePostApplicationProviderAction } = await import( const { executePostApplicationProviderAction } = await import(
"../../services/post-application/providers" "@server/services/post-application/providers"
); );
vi.mocked(executePostApplicationProviderAction).mockResolvedValueOnce({ vi.mocked(executePostApplicationProviderAction).mockResolvedValueOnce({
provider: "gmail", provider: "gmail",
@ -155,7 +155,7 @@ describe.sequential("Post-Application Provider actions API", () => {
it("maps provider service errors to standardized error responses", async () => { it("maps provider service errors to standardized error responses", async () => {
const { executePostApplicationProviderAction } = await import( const { executePostApplicationProviderAction } = await import(
"../../services/post-application/providers" "@server/services/post-application/providers"
); );
const { AppError } = await import("@infra/errors"); const { AppError } = await import("@infra/errors");
vi.mocked(executePostApplicationProviderAction).mockRejectedValueOnce( vi.mocked(executePostApplicationProviderAction).mockRejectedValueOnce(

View File

@ -2,13 +2,13 @@ import { randomUUID } from "node:crypto";
import { badRequest, serviceUnavailable, upstreamError } from "@infra/errors"; import { badRequest, serviceUnavailable, upstreamError } from "@infra/errors";
import { asyncRoute, fail, ok } from "@infra/http"; import { asyncRoute, fail, ok } from "@infra/http";
import { logger } from "@infra/logger"; import { logger } from "@infra/logger";
import { executePostApplicationProviderAction } from "@server/services/post-application/providers";
import { import {
POST_APPLICATION_PROVIDER_ACTIONS, POST_APPLICATION_PROVIDER_ACTIONS,
POST_APPLICATION_PROVIDERS, POST_APPLICATION_PROVIDERS,
} from "@shared/types"; } from "@shared/types";
import { type Request, type Response, Router } from "express"; import { type Request, type Response, Router } from "express";
import { z } from "zod"; import { z } from "zod";
import { executePostApplicationProviderAction } from "../../services/post-application/providers";
const providerActionParamsSchema = z.object({ const providerActionParamsSchema = z.object({
provider: z.enum(POST_APPLICATION_PROVIDERS), provider: z.enum(POST_APPLICATION_PROVIDERS),

View File

@ -29,9 +29,9 @@ describe.sequential("Post-Application Review Workflow API", () => {
message: PostApplicationMessage; message: PostApplicationMessage;
jobId: string; jobId: string;
}> { }> {
const { createJob } = await import("../../repositories/jobs"); const { createJob } = await import("@server/repositories/jobs");
const { upsertPostApplicationMessage } = await import( const { upsertPostApplicationMessage } = await import(
"../../repositories/post-application-messages" "@server/repositories/post-application-messages"
); );
const job = await createJob({ const job = await createJob({
@ -93,7 +93,7 @@ describe.sequential("Post-Application Review Workflow API", () => {
it("approves an inbox item and writes stage event", async () => { it("approves an inbox item and writes stage event", async () => {
const { message, jobId } = await seedPendingMessage(); const { message, jobId } = await seedPendingMessage();
const { db, schema } = await import("../../db"); const { db, schema } = await import("@server/db");
const res = await fetch( const res = await fetch(
`${baseUrl}/api/post-application/inbox/${message.id}/approve`, `${baseUrl}/api/post-application/inbox/${message.id}/approve`,
@ -121,7 +121,7 @@ describe.sequential("Post-Application Review Workflow API", () => {
it("returns conflict on second approve and increments sync-run approval once", async () => { it("returns conflict on second approve and increments sync-run approval once", async () => {
const { startPostApplicationSyncRun, getPostApplicationSyncRunById } = const { startPostApplicationSyncRun, getPostApplicationSyncRunById } =
await import("../../repositories/post-application-sync-runs"); await import("@server/repositories/post-application-sync-runs");
const run = await startPostApplicationSyncRun({ const run = await startPostApplicationSyncRun({
provider: "gmail", provider: "gmail",
accountKey: "default", accountKey: "default",
@ -218,7 +218,7 @@ describe.sequential("Post-Application Review Workflow API", () => {
it("lists messages for a sync run", async () => { it("lists messages for a sync run", async () => {
const { startPostApplicationSyncRun } = await import( const { startPostApplicationSyncRun } = await import(
"../../repositories/post-application-sync-runs" "@server/repositories/post-application-sync-runs"
); );
const run = await startPostApplicationSyncRun({ const run = await startPostApplicationSyncRun({
provider: "gmail", provider: "gmail",
@ -243,7 +243,7 @@ describe.sequential("Post-Application Review Workflow API", () => {
const { message, jobId } = await seedPendingMessage({ const { message, jobId } = await seedPendingMessage({
stageTarget: "rejected", stageTarget: "rejected",
}); });
const { db, schema } = await import("../../db"); const { db, schema } = await import("@server/db");
const res = await fetch( const res = await fetch(
`${baseUrl}/api/post-application/inbox/${message.id}/approve`, `${baseUrl}/api/post-application/inbox/${message.id}/approve`,
@ -274,7 +274,7 @@ describe.sequential("Post-Application Review Workflow API", () => {
const { message, jobId } = await seedPendingMessage({ const { message, jobId } = await seedPendingMessage({
stageTarget: "withdrawn", stageTarget: "withdrawn",
}); });
const { db, schema } = await import("../../db"); const { db, schema } = await import("@server/db");
const res = await fetch( const res = await fetch(
`${baseUrl}/api/post-application/inbox/${message.id}/approve`, `${baseUrl}/api/post-application/inbox/${message.id}/approve`,

View File

@ -1,12 +1,5 @@
import { badRequest } from "@infra/errors"; import { badRequest } from "@infra/errors";
import { asyncRoute, fail, ok } from "@infra/http"; import { asyncRoute, fail, ok } from "@infra/http";
import {
APPLICATION_STAGES,
POST_APPLICATION_PROVIDERS,
POST_APPLICATION_ROUTER_STAGE_TARGETS,
} from "@shared/types";
import { type Request, type Response, Router } from "express";
import { z } from "zod";
import { import {
approvePostApplicationInboxItem, approvePostApplicationInboxItem,
denyPostApplicationInboxItem, denyPostApplicationInboxItem,
@ -14,7 +7,14 @@ import {
listPostApplicationReviewRuns, listPostApplicationReviewRuns,
listPostApplicationRunMessages, listPostApplicationRunMessages,
runPostApplicationInboxAction, runPostApplicationInboxAction,
} from "../../services/post-application/review"; } from "@server/services/post-application/review";
import {
APPLICATION_STAGES,
POST_APPLICATION_PROVIDERS,
POST_APPLICATION_ROUTER_STAGE_TARGETS,
} from "@shared/types";
import { type Request, type Response, Router } from "express";
import { z } from "zod";
const listQuerySchema = z.object({ const listQuerySchema = z.object({
provider: z.enum(POST_APPLICATION_PROVIDERS).default("gmail"), provider: z.enum(POST_APPLICATION_PROVIDERS).default("gmail"),

View File

@ -3,7 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { startServer, stopServer } from "./test-utils"; import { startServer, stopServer } from "./test-utils";
// Mock the rxresume-v4 service // Mock the rxresume-v4 service
vi.mock("../../services/rxresume-v4", () => ({ vi.mock("@server/services/rxresume-v4", () => ({
getResume: vi.fn(), getResume: vi.fn(),
listResumes: vi.fn(), listResumes: vi.fn(),
RxResumeCredentialsError: class RxResumeCredentialsError extends Error { RxResumeCredentialsError: class RxResumeCredentialsError extends Error {
@ -15,13 +15,13 @@ vi.mock("../../services/rxresume-v4", () => ({
})); }));
// Mock the profile service // Mock the profile service
vi.mock("../../services/profile", () => ({ vi.mock("@server/services/profile", () => ({
getProfile: vi.fn(), getProfile: vi.fn(),
clearProfileCache: vi.fn(), clearProfileCache: vi.fn(),
})); }));
// Mock the settings repository // Mock the settings repository
vi.mock("../../repositories/settings", async (importOriginal) => { vi.mock("@server/repositories/settings", async (importOriginal) => {
const original = (await importOriginal()) as Record<string, unknown>; const original = (await importOriginal()) as Record<string, unknown>;
return { return {
...original, ...original,
@ -29,12 +29,12 @@ vi.mock("../../repositories/settings", async (importOriginal) => {
}; };
}); });
import { getSetting } from "../../repositories/settings"; import { getSetting } from "@server/repositories/settings";
import { getProfile } from "../../services/profile"; import { getProfile } from "@server/services/profile";
import { import {
getResume, getResume,
RxResumeCredentialsError, RxResumeCredentialsError,
} from "../../services/rxresume-v4"; } from "@server/services/rxresume-v4";
describe.sequential("Profile API routes", () => { describe.sequential("Profile API routes", () => {
let server: Server; let server: Server;

View File

@ -1,13 +1,13 @@
import { type Request, type Response, Router } from "express"; import { isDemoMode } from "@server/config/demo";
import { isDemoMode } from "../../config/demo"; import { DEMO_PROJECT_CATALOG } from "@server/config/demo-defaults";
import { DEMO_PROJECT_CATALOG } from "../../config/demo-defaults"; import { getSetting } from "@server/repositories/settings";
import { getSetting } from "../../repositories/settings"; import { clearProfileCache, getProfile } from "@server/services/profile";
import { clearProfileCache, getProfile } from "../../services/profile"; import { extractProjectsFromProfile } from "@server/services/resumeProjects";
import { extractProjectsFromProfile } from "../../services/resumeProjects";
import { import {
getResume, getResume,
RxResumeCredentialsError, RxResumeCredentialsError,
} from "../../services/rxresume-v4"; } from "@server/services/rxresume-v4";
import { type Request, type Response, Router } from "express";
export const profileRouter = Router(); export const profileRouter = Router();

View File

@ -1,4 +1,5 @@
import { logger } from "@infra/logger"; import { logger } from "@infra/logger";
import { isDemoMode, sendDemoBlocked } from "@server/config/demo";
import { setBackupSettings } from "@server/services/backup/index"; import { setBackupSettings } from "@server/services/backup/index";
import { extractProjectsFromProfile } from "@server/services/resumeProjects"; import { extractProjectsFromProfile } from "@server/services/resumeProjects";
import { import {
@ -10,7 +11,6 @@ import { getEffectiveSettings } from "@server/services/settings";
import { applySettingsUpdates } from "@server/services/settings-update"; import { applySettingsUpdates } from "@server/services/settings-update";
import { updateSettingsSchema } from "@shared/settings-schema"; import { updateSettingsSchema } from "@shared/settings-schema";
import { type Request, type Response, Router } from "express"; import { type Request, type Response, Router } from "express";
import { isDemoMode, sendDemoBlocked } from "../../config/demo";
export const settingsRouter = Router(); export const settingsRouter = Router();

View File

@ -4,7 +4,7 @@ import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { vi } from "vitest"; import { vi } from "vitest";
vi.mock("../../pipeline/index", () => { vi.mock("@server/pipeline/index", () => {
const progress = { const progress = {
step: "idle", step: "idle",
message: "Ready", message: "Ready",
@ -51,19 +51,19 @@ vi.mock("../../pipeline/index", () => {
}; };
}); });
vi.mock("../../services/manualJob", () => ({ vi.mock("@server/services/manualJob", () => ({
inferManualJobDetails: vi.fn(), inferManualJobDetails: vi.fn(),
})); }));
vi.mock("../../services/scorer", () => ({ vi.mock("@server/services/scorer", () => ({
scoreJobSuitability: vi.fn(), scoreJobSuitability: vi.fn(),
})); }));
vi.mock("../../services/profile", () => ({ vi.mock("@server/services/profile", () => ({
getProfile: vi.fn().mockResolvedValue({}), getProfile: vi.fn().mockResolvedValue({}),
})); }));
vi.mock("../../services/visa-sponsors/index", () => ({ vi.mock("@server/services/visa-sponsors/index", () => ({
getStatus: vi.fn(), getStatus: vi.fn(),
searchSponsors: vi.fn(), searchSponsors: vi.fn(),
getOrganizationDetails: vi.fn(), getOrganizationDetails: vi.fn(),
@ -102,13 +102,13 @@ export async function startServer(options?: {
...envOverrides, ...envOverrides,
}; };
await import("../../db/migrate"); await import("@server/db/migrate");
const { applyStoredEnvOverrides } = await import( const { applyStoredEnvOverrides } = await import(
"../../services/envSettings" "@server/services/envSettings"
); );
const { createApp } = await import("../../app"); const { createApp } = await import("../../app");
const { closeDb } = await import("../../db/index"); const { closeDb } = await import("@server/db/index");
const { getPipelineStatus } = await import("../../pipeline/index"); const { getPipelineStatus } = await import("@server/pipeline/index");
vi.mocked(getPipelineStatus).mockReturnValue({ isRunning: false }); vi.mocked(getPipelineStatus).mockReturnValue({ isRunning: false });
await applyStoredEnvOverrides(); await applyStoredEnvOverrides();

View File

@ -19,7 +19,7 @@ describe.sequential("Tracer links routes", () => {
}); });
async function seedTracerFixtures() { async function seedTracerFixtures() {
const { db, schema } = await import("../../db"); const { db, schema } = await import("@server/db");
const now = new Date().toISOString(); const now = new Date().toISOString();
const jobId = "job-tracer-fixture"; const jobId = "job-tracer-fixture";

View File

@ -1,13 +1,13 @@
import { badRequest, notFound } from "@infra/errors"; import { badRequest, notFound } from "@infra/errors";
import { asyncRoute, fail, ok } from "@infra/http"; import { asyncRoute, fail, ok } from "@infra/http";
import { type Request, type Response, Router } from "express"; import * as jobsRepo from "@server/repositories/jobs";
import { z } from "zod";
import * as jobsRepo from "../../repositories/jobs";
import { import {
getJobTracerLinksAnalytics, getJobTracerLinksAnalytics,
getTracerAnalytics, getTracerAnalytics,
getTracerReadiness, getTracerReadiness,
} from "../../services/tracer-links"; } from "@server/services/tracer-links";
import { type Request, type Response, Router } from "express";
import { z } from "zod";
export const tracerLinksRouter = Router(); export const tracerLinksRouter = Router();

View File

@ -18,7 +18,7 @@ describe.sequential("Visa sponsors API routes", () => {
it("returns status and surfaces update errors", async () => { it("returns status and surfaces update errors", async () => {
const { getStatus, downloadLatestCsv } = await import( const { getStatus, downloadLatestCsv } = await import(
"../../services/visa-sponsors/index" "@server/services/visa-sponsors/index"
); );
vi.mocked(getStatus).mockReturnValue({ vi.mocked(getStatus).mockReturnValue({
lastUpdated: null, lastUpdated: null,
@ -46,7 +46,7 @@ describe.sequential("Visa sponsors API routes", () => {
it("validates search payloads and handles missing organizations", async () => { it("validates search payloads and handles missing organizations", async () => {
const { searchSponsors, getOrganizationDetails } = await import( const { searchSponsors, getOrganizationDetails } = await import(
"../../services/visa-sponsors/index" "@server/services/visa-sponsors/index"
); );
vi.mocked(searchSponsors).mockReturnValue([ vi.mocked(searchSponsors).mockReturnValue([
{ {

View File

@ -1,3 +1,6 @@
import { notFound } from "@infra/errors";
import { fail } from "@infra/http";
import * as visaSponsors from "@server/services/visa-sponsors/index";
import type { import type {
ApiResponse, ApiResponse,
VisaSponsorSearchResponse, VisaSponsorSearchResponse,
@ -6,8 +9,6 @@ import type {
import { type Request, type Response, Router } from "express"; import { type Request, type Response, Router } from "express";
import { z } from "zod"; import { z } from "zod";
import * as visaSponsors from "../../services/visa-sponsors/index";
export const visaSponsorsRouter = Router(); export const visaSponsorsRouter = Router();
/** /**
@ -74,9 +75,7 @@ visaSponsorsRouter.get(
const entries = visaSponsors.getOrganizationDetails(name); const entries = visaSponsors.getOrganizationDetails(name);
if (entries.length === 0) { if (entries.length === 0) {
return res return fail(res, notFound("Organization not found"));
.status(404)
.json({ success: false, error: "Organization not found" });
} }
res.json({ res.json({

View File

@ -2,10 +2,10 @@ import { unauthorized } from "@infra/errors";
import { fail, okWithMeta } from "@infra/http"; import { fail, okWithMeta } from "@infra/http";
import { logger } from "@infra/logger"; import { logger } from "@infra/logger";
import { runWithRequestContext } from "@infra/request-context"; import { runWithRequestContext } from "@infra/request-context";
import { isDemoMode } from "@server/config/demo";
import { runPipeline } from "@server/pipeline/index";
import { simulatePipelineRun } from "@server/services/demo-simulator";
import { type Request, type Response, Router } from "express"; import { type Request, type Response, Router } from "express";
import { isDemoMode } from "../../config/demo";
import { runPipeline } from "../../pipeline/index";
import { simulatePipelineRun } from "../../services/demo-simulator";
export const webhookRouter = Router(); export const webhookRouter = Router();

View File

@ -3,15 +3,15 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import { getProgress, resetProgress } from "../progress"; import { getProgress, resetProgress } from "../progress";
import { discoverJobsStep } from "./discover-jobs"; import { discoverJobsStep } from "./discover-jobs";
vi.mock("../../repositories/settings", () => ({ vi.mock("@server/repositories/settings", () => ({
getAllSettings: vi.fn(), getAllSettings: vi.fn(),
})); }));
vi.mock("../../repositories/jobs", () => ({ vi.mock("@server/repositories/jobs", () => ({
getAllJobUrls: vi.fn().mockResolvedValue([]), getAllJobUrls: vi.fn().mockResolvedValue([]),
})); }));
vi.mock("../../extractors/registry", () => ({ vi.mock("@server/extractors/registry", () => ({
getExtractorRegistry: vi.fn(), getExtractorRegistry: vi.fn(),
})); }));
@ -33,8 +33,8 @@ describe("discoverJobsStep", () => {
}); });
it("aggregates source errors for enabled sources", async () => { it("aggregates source errors for enabled sources", async () => {
const settingsRepo = await import("../../repositories/settings"); const settingsRepo = await import("@server/repositories/settings");
const registryModule = await import("../../extractors/registry"); const registryModule = await import("@server/extractors/registry");
const jobspyManifest = { const jobspyManifest = {
id: "jobspy", id: "jobspy",
@ -93,8 +93,8 @@ describe("discoverJobsStep", () => {
}); });
it("throws when all enabled sources fail", async () => { it("throws when all enabled sources fail", async () => {
const settingsRepo = await import("../../repositories/settings"); const settingsRepo = await import("@server/repositories/settings");
const registryModule = await import("../../extractors/registry"); const registryModule = await import("@server/extractors/registry");
const ukvisaManifest = { const ukvisaManifest = {
id: "ukvisajobs", id: "ukvisajobs",
@ -130,8 +130,8 @@ describe("discoverJobsStep", () => {
}); });
it("throws when all requested sources are incompatible for country", async () => { it("throws when all requested sources are incompatible for country", async () => {
const settingsRepo = await import("../../repositories/settings"); const settingsRepo = await import("@server/repositories/settings");
const registryModule = await import("../../extractors/registry"); const registryModule = await import("@server/extractors/registry");
vi.mocked(settingsRepo.getAllSettings).mockResolvedValue({ vi.mocked(settingsRepo.getAllSettings).mockResolvedValue({
searchTerms: JSON.stringify(["engineer"]), searchTerms: JSON.stringify(["engineer"]),
@ -157,8 +157,8 @@ describe("discoverJobsStep", () => {
}); });
it("does not throw when no sources are requested", async () => { it("does not throw when no sources are requested", async () => {
const settingsRepo = await import("../../repositories/settings"); const settingsRepo = await import("@server/repositories/settings");
const registryModule = await import("../../extractors/registry"); const registryModule = await import("@server/extractors/registry");
vi.mocked(settingsRepo.getAllSettings).mockResolvedValue({ vi.mocked(settingsRepo.getAllSettings).mockResolvedValue({
searchTerms: JSON.stringify(["engineer"]), searchTerms: JSON.stringify(["engineer"]),
@ -183,8 +183,8 @@ describe("discoverJobsStep", () => {
}); });
it("drops discovered jobs when employer matches blocked company keywords", async () => { it("drops discovered jobs when employer matches blocked company keywords", async () => {
const settingsRepo = await import("../../repositories/settings"); const settingsRepo = await import("@server/repositories/settings");
const registryModule = await import("../../extractors/registry"); const registryModule = await import("@server/extractors/registry");
const jobspyManifest = { const jobspyManifest = {
id: "jobspy", id: "jobspy",
@ -236,8 +236,8 @@ describe("discoverJobsStep", () => {
}); });
it("applies shared city filtering for sources without native city filtering", async () => { it("applies shared city filtering for sources without native city filtering", async () => {
const settingsRepo = await import("../../repositories/settings"); const settingsRepo = await import("@server/repositories/settings");
const registryModule = await import("../../extractors/registry"); const registryModule = await import("@server/extractors/registry");
const gradcrackerManifest = { const gradcrackerManifest = {
id: "gradcracker", id: "gradcracker",
@ -313,9 +313,9 @@ describe("discoverJobsStep", () => {
}); });
it("tracks source completion counters across source transitions", async () => { it("tracks source completion counters across source transitions", async () => {
const settingsRepo = await import("../../repositories/settings"); const settingsRepo = await import("@server/repositories/settings");
const jobsRepo = await import("../../repositories/jobs"); const jobsRepo = await import("@server/repositories/jobs");
const registryModule = await import("../../extractors/registry"); const registryModule = await import("@server/extractors/registry");
const jobspyManifest = { const jobspyManifest = {
id: "jobspy", id: "jobspy",

View File

@ -1,5 +1,9 @@
import { logger } from "@infra/logger"; import { logger } from "@infra/logger";
import { sanitizeUnknown } from "@infra/sanitize"; import { sanitizeUnknown } from "@infra/sanitize";
import { getExtractorRegistry } from "@server/extractors/registry";
import { getAllJobUrls } from "@server/repositories/jobs";
import * as settingsRepo from "@server/repositories/settings";
import { asyncPool } from "@server/utils/async-pool";
import { import {
formatCountryLabel, formatCountryLabel,
isSourceAllowedForCountry, isSourceAllowedForCountry,
@ -12,10 +16,6 @@ import {
shouldApplyStrictCityFilter, shouldApplyStrictCityFilter,
} from "@shared/search-cities.js"; } from "@shared/search-cities.js";
import type { CreateJobInput, PipelineConfig } from "@shared/types"; import type { CreateJobInput, PipelineConfig } from "@shared/types";
import { getExtractorRegistry } from "../../extractors/registry";
import { getAllJobUrls } from "../../repositories/jobs";
import * as settingsRepo from "../../repositories/settings";
import { asyncPool } from "../../utils/async-pool";
import { type CrawlSource, progressHelpers, updateProgress } from "../progress"; import { type CrawlSource, progressHelpers, updateProgress } from "../progress";
const DISCOVERY_CONCURRENCY = 3; const DISCOVERY_CONCURRENCY = 3;

View File

@ -1,6 +1,6 @@
import { logger } from "@infra/logger"; import { logger } from "@infra/logger";
import * as jobsRepo from "@server/repositories/jobs";
import type { CreateJobInput } from "@shared/types"; import type { CreateJobInput } from "@shared/types";
import * as jobsRepo from "../../repositories/jobs";
import { progressHelpers } from "../progress"; import { progressHelpers } from "../progress";
export async function importJobsStep(args: { export async function importJobsStep(args: {

View File

@ -1,5 +1,5 @@
import { logger } from "@infra/logger"; import { logger } from "@infra/logger";
import { getProfile } from "../../services/profile"; import { getProfile } from "@server/services/profile";
export async function loadProfileStep(): Promise<Record<string, unknown>> { export async function loadProfileStep(): Promise<Record<string, unknown>> {
logger.info("Loading profile"); logger.info("Loading profile");

View File

@ -1,6 +1,6 @@
import { logger } from "@infra/logger"; import { logger } from "@infra/logger";
import { sanitizeWebhookPayload } from "@infra/sanitize"; import { sanitizeWebhookPayload } from "@infra/sanitize";
import * as settingsRepo from "../../repositories/settings"; import * as settingsRepo from "@server/repositories/settings";
export async function notifyPipelineWebhookStep( export async function notifyPipelineWebhookStep(
event: "pipeline.completed" | "pipeline.failed", event: "pipeline.completed" | "pipeline.failed",

View File

@ -1,5 +1,5 @@
import { logger } from "@infra/logger"; import { logger } from "@infra/logger";
import { asyncPool } from "../../utils/async-pool"; import { asyncPool } from "@server/utils/async-pool";
import { progressHelpers, updateProgress } from "../progress"; import { progressHelpers, updateProgress } from "../progress";
import type { ScoredJob } from "./types"; import type { ScoredJob } from "./types";

View File

@ -10,20 +10,20 @@ vi.mock("@infra/logger", () => ({
}, },
})); }));
vi.mock("../../repositories/jobs", () => ({ vi.mock("@server/repositories/jobs", () => ({
getUnscoredDiscoveredJobs: vi.fn(), getUnscoredDiscoveredJobs: vi.fn(),
updateJob: vi.fn(), updateJob: vi.fn(),
})); }));
vi.mock("../../repositories/settings", () => ({ vi.mock("@server/repositories/settings", () => ({
getSetting: vi.fn(), getSetting: vi.fn(),
})); }));
vi.mock("../../services/scorer", () => ({ vi.mock("@server/services/scorer", () => ({
scoreJobSuitability: vi.fn(), scoreJobSuitability: vi.fn(),
})); }));
vi.mock("../../services/visa-sponsors/index", () => ({ vi.mock("@server/services/visa-sponsors/index", () => ({
searchSponsors: vi.fn(), searchSponsors: vi.fn(),
calculateSponsorMatchSummary: vi.fn(), calculateSponsorMatchSummary: vi.fn(),
})); }));
@ -40,10 +40,10 @@ describe("scoreJobsStep auto-skip behavior", () => {
beforeEach(async () => { beforeEach(async () => {
vi.clearAllMocks(); vi.clearAllMocks();
const jobsRepo = await import("../../repositories/jobs"); const jobsRepo = await import("@server/repositories/jobs");
const settingsRepo = await import("../../repositories/settings"); const settingsRepo = await import("@server/repositories/settings");
const scorer = await import("../../services/scorer"); const scorer = await import("@server/services/scorer");
const visaSponsors = await import("../../services/visa-sponsors/index"); const visaSponsors = await import("@server/services/visa-sponsors/index");
vi.mocked(jobsRepo.getUnscoredDiscoveredJobs).mockResolvedValue([ vi.mocked(jobsRepo.getUnscoredDiscoveredJobs).mockResolvedValue([
createJob({ createJob({
@ -68,8 +68,8 @@ describe("scoreJobsStep auto-skip behavior", () => {
}); });
it("auto-skips jobs when score is below threshold", async () => { it("auto-skips jobs when score is below threshold", async () => {
const settingsRepo = await import("../../repositories/settings"); const settingsRepo = await import("@server/repositories/settings");
const jobsRepo = await import("../../repositories/jobs"); const jobsRepo = await import("@server/repositories/jobs");
const { logger } = await import("@infra/logger"); const { logger } = await import("@infra/logger");
vi.mocked(settingsRepo.getSetting).mockResolvedValue("50"); vi.mocked(settingsRepo.getSetting).mockResolvedValue("50");
@ -94,9 +94,9 @@ describe("scoreJobsStep auto-skip behavior", () => {
}); });
it("does not auto-skip jobs when score equals threshold", async () => { it("does not auto-skip jobs when score equals threshold", async () => {
const settingsRepo = await import("../../repositories/settings"); const settingsRepo = await import("@server/repositories/settings");
const jobsRepo = await import("../../repositories/jobs"); const jobsRepo = await import("@server/repositories/jobs");
const scorer = await import("../../services/scorer"); const scorer = await import("@server/services/scorer");
const { logger } = await import("@infra/logger"); const { logger } = await import("@infra/logger");
vi.mocked(settingsRepo.getSetting).mockResolvedValue("50"); vi.mocked(settingsRepo.getSetting).mockResolvedValue("50");
@ -124,8 +124,8 @@ describe("scoreJobsStep auto-skip behavior", () => {
}); });
it("does not auto-skip when threshold setting is null", async () => { it("does not auto-skip when threshold setting is null", async () => {
const settingsRepo = await import("../../repositories/settings"); const settingsRepo = await import("@server/repositories/settings");
const jobsRepo = await import("../../repositories/jobs"); const jobsRepo = await import("@server/repositories/jobs");
vi.mocked(settingsRepo.getSetting).mockResolvedValue(null); vi.mocked(settingsRepo.getSetting).mockResolvedValue(null);
@ -138,8 +138,8 @@ describe("scoreJobsStep auto-skip behavior", () => {
}); });
it("does not auto-skip when threshold setting is NaN", async () => { it("does not auto-skip when threshold setting is NaN", async () => {
const settingsRepo = await import("../../repositories/settings"); const settingsRepo = await import("@server/repositories/settings");
const jobsRepo = await import("../../repositories/jobs"); const jobsRepo = await import("@server/repositories/jobs");
vi.mocked(settingsRepo.getSetting).mockResolvedValue("not-a-number"); vi.mocked(settingsRepo.getSetting).mockResolvedValue("not-a-number");
@ -152,8 +152,8 @@ describe("scoreJobsStep auto-skip behavior", () => {
}); });
it("never auto-skips applied jobs even when score is below threshold", async () => { it("never auto-skips applied jobs even when score is below threshold", async () => {
const settingsRepo = await import("../../repositories/settings"); const settingsRepo = await import("@server/repositories/settings");
const jobsRepo = await import("../../repositories/jobs"); const jobsRepo = await import("@server/repositories/jobs");
const { logger } = await import("@infra/logger"); const { logger } = await import("@infra/logger");
vi.mocked(settingsRepo.getSetting).mockResolvedValue("50"); vi.mocked(settingsRepo.getSetting).mockResolvedValue("50");
@ -185,8 +185,8 @@ describe("scoreJobsStep auto-skip behavior", () => {
}); });
it("scores multiple jobs and reports completion progress", async () => { it("scores multiple jobs and reports completion progress", async () => {
const jobsRepo = await import("../../repositories/jobs"); const jobsRepo = await import("@server/repositories/jobs");
const scorer = await import("../../services/scorer"); const scorer = await import("@server/services/scorer");
const { progressHelpers } = await import("../progress"); const { progressHelpers } = await import("../progress");
vi.mocked(jobsRepo.getUnscoredDiscoveredJobs).mockResolvedValue([ vi.mocked(jobsRepo.getUnscoredDiscoveredJobs).mockResolvedValue([
@ -217,8 +217,8 @@ describe("scoreJobsStep auto-skip behavior", () => {
}); });
it("stops before processing when cancellation is requested", async () => { it("stops before processing when cancellation is requested", async () => {
const jobsRepo = await import("../../repositories/jobs"); const jobsRepo = await import("@server/repositories/jobs");
const scorer = await import("../../services/scorer"); const scorer = await import("@server/services/scorer");
vi.mocked(jobsRepo.getUnscoredDiscoveredJobs).mockResolvedValue([ vi.mocked(jobsRepo.getUnscoredDiscoveredJobs).mockResolvedValue([
createJob({ createJob({

View File

@ -1,10 +1,10 @@
import { logger } from "@infra/logger"; import { logger } from "@infra/logger";
import * as jobsRepo from "@server/repositories/jobs";
import * as settingsRepo from "@server/repositories/settings";
import { scoreJobSuitability } from "@server/services/scorer";
import * as visaSponsors from "@server/services/visa-sponsors/index";
import { asyncPool } from "@server/utils/async-pool";
import type { Job } from "@shared/types"; import type { Job } from "@shared/types";
import * as jobsRepo from "../../repositories/jobs";
import * as settingsRepo from "../../repositories/settings";
import { scoreJobSuitability } from "../../services/scorer";
import * as visaSponsors from "../../services/visa-sponsors/index";
import { asyncPool } from "../../utils/async-pool";
import { progressHelpers, updateProgress } from "../progress"; import { progressHelpers, updateProgress } from "../progress";
import type { ScoredJob } from "./types"; import type { ScoredJob } from "./types";

View File

@ -6,11 +6,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import * as backup from "./index"; import * as backup from "./index";
// Mock the dataDir module // Mock the dataDir module
vi.mock("../../config/dataDir", () => ({ vi.mock("@server/config/dataDir", () => ({
getDataDir: vi.fn(), getDataDir: vi.fn(),
})); }));
import { getDataDir } from "../../config/dataDir"; import { getDataDir } from "@server/config/dataDir";
describe("Backup Service", () => { describe("Backup Service", () => {
let tempDir: string; let tempDir: string;

View File

@ -8,10 +8,10 @@
import fs from "node:fs"; import fs from "node:fs";
import type { FileHandle } from "node:fs/promises"; import type { FileHandle } from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import { getDataDir } from "@server/config/dataDir";
import { createScheduler } from "@server/utils/scheduler";
import type { BackupInfo } from "@shared/types"; import type { BackupInfo } from "@shared/types";
import Database from "better-sqlite3"; import Database from "better-sqlite3";
import { getDataDir } from "../../config/dataDir";
import { createScheduler } from "../../utils/scheduler";
const DB_FILENAME = "jobs.db"; const DB_FILENAME = "jobs.db";
const AUTO_BACKUP_PREFIX = "jobs_"; const AUTO_BACKUP_PREFIX = "jobs_";

View File

@ -1,6 +1,6 @@
import type { AppError } from "@infra/errors";
import { createJob } from "@shared/testing/factories"; import { createJob } from "@shared/testing/factories";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import type { AppError } from "../infra/errors";
import { buildJobChatPromptContext } from "./ghostwriter-context"; import { buildJobChatPromptContext } from "./ghostwriter-context";
vi.mock("../repositories/jobs", () => ({ vi.mock("../repositories/jobs", () => ({

View File

@ -1,7 +1,7 @@
import { badRequest, notFound } from "@infra/errors";
import { logger } from "@infra/logger"; import { logger } from "@infra/logger";
import { sanitizeUnknown } from "@infra/sanitize"; import { sanitizeUnknown } from "@infra/sanitize";
import type { Job, ResumeProfile } from "@shared/types"; import type { Job, ResumeProfile } from "@shared/types";
import { badRequest, notFound } from "../infra/errors";
import * as jobsRepo from "../repositories/jobs"; import * as jobsRepo from "../repositories/jobs";
import { getProfile } from "./profile"; import { getProfile } from "./profile";
import { getEffectiveSettings } from "./settings"; import { getEffectiveSettings } from "./settings";

View File

@ -1,13 +1,13 @@
import { logger } from "@infra/logger";
import { getRequestId } from "@infra/request-context";
import type { JobChatMessage, JobChatRun } from "@shared/types";
import { import {
badRequest, badRequest,
conflict, conflict,
notFound, notFound,
requestTimeout, requestTimeout,
upstreamError, upstreamError,
} from "../infra/errors"; } from "@infra/errors";
import { logger } from "@infra/logger";
import { getRequestId } from "@infra/request-context";
import type { JobChatMessage, JobChatRun } from "@shared/types";
import * as jobChatRepo from "../repositories/ghostwriter"; import * as jobChatRepo from "../repositories/ghostwriter";
import * as settingsRepo from "../repositories/settings"; import * as settingsRepo from "../repositories/settings";
import { buildJobChatPromptContext } from "./ghostwriter-context"; import { buildJobChatPromptContext } from "./ghostwriter-context";

View File

@ -1,12 +1,14 @@
import { logger } from "@infra/logger"; import { logger } from "@infra/logger";
export function parseJsonContent<T>(content: string, jobId?: string): T { export function stripMarkdownCodeFences(content: string): string {
let candidate = content.trim(); return content
candidate = candidate
.replace(/```(?:json|JSON)?\s*/g, "") .replace(/```(?:json|JSON)?\s*/g, "")
.replace(/```/g, "") .replace(/```/g, "")
.trim(); .trim();
}
export function parseJsonContent<T>(content: string, jobId?: string): T {
let candidate = stripMarkdownCodeFences(content);
const firstBrace = candidate.indexOf("{"); const firstBrace = candidate.indexOf("{");
const lastBrace = candidate.lastIndexOf("}"); const lastBrace = candidate.lastIndexOf("}");

View File

@ -11,6 +11,7 @@ import type {
PostApplicationRouterStageTarget, PostApplicationRouterStageTarget,
} from "@shared/types"; } from "@shared/types";
import { POST_APPLICATION_ROUTER_STAGE_TARGETS } from "@shared/types"; import { POST_APPLICATION_ROUTER_STAGE_TARGETS } from "@shared/types";
import { normalizeWhitespace } from "@shared/utils/string";
export const ROUTER_EMAIL_CHAR_LIMIT = 12_000; export const ROUTER_EMAIL_CHAR_LIMIT = 12_000;
@ -91,7 +92,7 @@ export function minifyActiveJobs(jobs: Job[]): Array<{
} }
function sanitizeJobPromptValue(value: string): string { function sanitizeJobPromptValue(value: string): string {
return value.replace(/\s+/g, " ").trim(); return normalizeWhitespace(value);
} }
export function buildIndexedActiveJobs( export function buildIndexedActiveJobs(

View File

@ -1,4 +1,5 @@
import { requestTimeout } from "@infra/errors"; import { requestTimeout } from "@infra/errors";
import { normalizeWhitespace } from "@shared/utils/string";
import { convert } from "html-to-text"; import { convert } from "html-to-text";
export const GMAIL_HTTP_TIMEOUT_MS = 15_000; export const GMAIL_HTTP_TIMEOUT_MS = 15_000;
@ -313,7 +314,7 @@ function cleanEmailHtmlForLlm(htmlContent: string): string {
} }
function normalizeChunkForDedup(value: string): string { function normalizeChunkForDedup(value: string): string {
return value.replace(/\s+/g, " ").trim().toLowerCase(); return normalizeWhitespace(value).toLowerCase();
} }
function decodeBase64Url(value: string): string { function decodeBase64Url(value: string): string {

View File

@ -3,6 +3,7 @@ import type {
ResumeProjectCatalogItem, ResumeProjectCatalogItem,
ResumeProjectsSettings, ResumeProjectsSettings,
} from "@shared/types"; } from "@shared/types";
import { stripHtmlTags } from "@shared/utils/string";
type ResumeProjectSelectionItem = ResumeProjectCatalogItem & { type ResumeProjectSelectionItem = ResumeProjectCatalogItem & {
summaryText: string; summaryText: string;
@ -156,8 +157,7 @@ export function resolveResumeProjectsSettings(args: {
} }
export function stripHtml(input: string): string { export function stripHtml(input: string): string {
const withoutTags = input.replace(/<[^>]*>/g, " "); return stripHtmlTags(input);
return withoutTags.replace(/\s+/g, " ").trim();
} }
function uniqueStrings(values: string[]): string[] { function uniqueStrings(values: string[]): string[] {

View File

@ -5,6 +5,7 @@
// - The v5 client should be a drop-in replacement in the future. // - The v5 client should be a drop-in replacement in the future.
import type { ResumeData } from "@shared/rxresume-schema"; import type { ResumeData } from "@shared/rxresume-schema";
import { normalizeWhitespace } from "@shared/utils/string";
type AnyObj = Record<string, unknown>; type AnyObj = Record<string, unknown>;
const MAX_ERROR_SNIPPET = 300; const MAX_ERROR_SNIPPET = 300;
@ -457,6 +458,6 @@ export class RxResumeClient {
function sanitizeResponseSnippet(text: string): string { function sanitizeResponseSnippet(text: string): string {
if (!text) return ""; if (!text) return "";
const compact = text.replace(/\s+/g, " ").trim(); const compact = normalizeWhitespace(text);
return compact.slice(0, MAX_ERROR_SNIPPET); return compact.slice(0, MAX_ERROR_SNIPPET);
} }

View File

@ -7,6 +7,7 @@ import type { Job } from "@shared/types";
import { getSetting } from "../repositories/settings"; import { getSetting } from "../repositories/settings";
import { LlmService } from "./llm/service"; import { LlmService } from "./llm/service";
import type { JsonSchemaDefinition } from "./llm/types"; import type { JsonSchemaDefinition } from "./llm/types";
import { stripMarkdownCodeFences } from "./llm/utils/json";
import { getEffectiveSettings } from "./settings"; import { getEffectiveSettings } from "./settings";
interface SuitabilityResult { interface SuitabilityResult {
@ -162,10 +163,7 @@ export function parseJsonFromContent(
let candidate = content.trim(); let candidate = content.trim();
// Step 1: Remove markdown code fences (with or without language specifier) // Step 1: Remove markdown code fences (with or without language specifier)
candidate = candidate candidate = stripMarkdownCodeFences(candidate);
.replace(/```(?:json|JSON)?\s*/g, "")
.replace(/```/g, "")
.trim();
// Step 2: Try to extract JSON object if there's surrounding text // Step 2: Try to extract JSON object if there's surrounding text
const jsonMatch = candidate.match(/\{[\s\S]*\}/); const jsonMatch = candidate.match(/\{[\s\S]*\}/);

View File

@ -6,8 +6,14 @@
import fs from "node:fs"; import fs from "node:fs";
import path from "node:path"; import path from "node:path";
import { getDataDir } from "../../config/dataDir"; import { getDataDir } from "@server/config/dataDir";
import { createScheduler } from "../../utils/scheduler"; import { createScheduler } from "@server/utils/scheduler";
import type {
VisaSponsor,
VisaSponsorSearchResult,
VisaSponsorStatusResponse,
} from "@shared/types";
import { normalizeWhitespace } from "@shared/utils/string";
const DATA_DIR = path.join(getDataDir(), "visa-sponsors"); const DATA_DIR = path.join(getDataDir(), "visa-sponsors");
@ -16,28 +22,8 @@ if (!fs.existsSync(DATA_DIR)) {
fs.mkdirSync(DATA_DIR, { recursive: true }); fs.mkdirSync(DATA_DIR, { recursive: true });
} }
export interface VisaSponsor { export type { VisaSponsor, VisaSponsorSearchResult };
organisationName: string; export type VisaSponsorStatus = VisaSponsorStatusResponse;
townCity: string;
county: string;
typeRating: string;
route: string;
}
export interface VisaSponsorSearchResult {
sponsor: VisaSponsor;
score: number;
matchedName: string;
}
export interface VisaSponsorStatus {
lastUpdated: string | null;
csvPath: string | null;
totalSponsors: number;
isUpdating: boolean;
nextScheduledUpdate: string | null;
error: string | null;
}
// Common company suffixes to strip during comparison // Common company suffixes to strip during comparison
const COMPANY_SUFFIXES = [ const COMPANY_SUFFIXES = [
@ -86,7 +72,7 @@ export function normalizeCompanyName(name: string): string {
} }
// Collapse whitespace // Collapse whitespace
normalized = normalized.replace(/\s+/g, " ").trim(); normalized = normalizeWhitespace(normalized);
return normalized; return normalized;
} }

View File

@ -0,0 +1,7 @@
export function normalizeWhitespace(value: string): string {
return value.replace(/\s+/g, " ").trim();
}
export function stripHtmlTags(value: string): string {
return normalizeWhitespace(value.replace(/<[^>]*>/g, " "));
}