Use logger! add shim to convert backend responses to same format (#84)

* chore(orchestrator): add @infra import alias

* feat(server): add error/http/context/logger/sanitize infrastructure

* refactor(core): propagate request context, structured logs, and sanitization

* test/docs: update API contract assertions and contributor standards

* all pages working

* normalizing
This commit is contained in:
Shaheer Sarfaraz 2026-02-04 23:07:24 +00:00 committed by GitHub
parent 82b261c7bc
commit 16a8f1d15a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
46 changed files with 1136 additions and 348 deletions

58
AGENTS.md Normal file
View File

@ -0,0 +1,58 @@
# Error/Logging/Sanitization Standards
This project uses strict operability and privacy defaults for server-side code.
## API Response Contract
For all `/api/*` routes, return:
- Success: `{ ok: true, data, meta?: { requestId } }`
- Error: `{ ok: false, error: { code, message, details? }, meta: { requestId } }`
Use consistent status/code mapping:
- `400 INVALID_REQUEST`
- `401 UNAUTHORIZED`
- `403 FORBIDDEN`
- `404 NOT_FOUND`
- `408 REQUEST_TIMEOUT`
- `409 CONFLICT`
- `422 UNPROCESSABLE_ENTITY`
- `500 INTERNAL_ERROR`
- `502 UPSTREAM_ERROR`
- `503 SERVICE_UNAVAILABLE`
## Correlation IDs
- Honor inbound `x-request-id` when present; otherwise generate one.
- Always return `x-request-id` header.
- Include request ID in API responses (`meta.requestId`) and logs.
- Propagate context into async flows (especially pipeline run and per-job work) so logs include `pipelineRunId` / `jobId` when available.
## Logging Rules
- Use the shared logger wrapper (`infra/logger.ts`) in core server paths.
- Do not add direct `console.log`, `console.warn`, or `console.error` in core paths.
- Log structured objects, not free-form dumps.
- Include useful context fields (e.g. `requestId`, `pipelineRunId`, `jobId`, `route`, `status`).
## Redaction and Sanitization
- Always sanitize objects before logging or returning in error `details`.
- Redact sensitive keys by default (`authorization`, `cookie`, `password`, `secret`, `token`, `apiKey`, etc.).
- Truncate large payloads and long strings.
- Do not throw/log raw upstream response bodies, full webhook bodies, or large `JSON.stringify(...)` blobs.
## Webhook and LLM Payload Defaults
- Webhooks: send minimal whitelisted payloads by default.
- LLM prompts: send only required profile/job fields; avoid unnecessary PII.
- Document external payload behavior when adding new integrations.
## PR Checklist (Routes/Services)
- API responses follow `{ ok, data/error, meta.requestId }`.
- Status/code mapping is correct and consistent.
- Request/correlation IDs appear in logs and async workflows.
- No raw sensitive payload logging or raw upstream body throws.
- New/changed webhook or LLM payloads are sanitized and documented.

View File

@ -92,3 +92,11 @@ POST /api/jobs/:id/generate-pdf
- `processing` is transient. If PDF generation fails, the job is reverted back to `discovered`. - `processing` is transient. If PDF generation fails, the job is reverted back to `discovered`.
- The PDF is served at `/pdfs/resume_<jobId>.pdf` and cache-busted with the job?s `updatedAt` timestamp. - The PDF is served at `/pdfs/resume_<jobId>.pdf` and cache-busted with the job?s `updatedAt` timestamp.
- If a job is `skipped` or `applied` and you want to re-open it, you can PATCH its `status` back to `discovered`. - If a job is `skipped` or `applied` and you want to re-open it, you can PATCH its `status` back to `discovered`.
## External payload and sanitization defaults
- **LLM providers** receive only prompt inputs required for scoring/tailoring/project selection/manual extraction tasks.
- By default, prompt construction uses minimized profile/job fields and avoids sending unnecessary sensitive data.
- **Webhook payloads** are sanitized and whitelisted by default; large/sensitive blobs are not sent.
- Server logs and error details are redacted/truncated by default (secrets, tokens, cookies, passwords, API keys, and oversized payload fields).
- Correlation data is included in logs (`requestId`, and when available `pipelineRunId` / `jobId`) to improve traceability without exposing raw payloads.

View File

@ -35,6 +35,45 @@ import { trackEvent } from "@/lib/analytics";
const API_BASE = "/api"; const API_BASE = "/api";
class ApiClientError extends Error {
requestId?: string;
constructor(message: string, requestId?: string) {
super(requestId ? `${message} (requestId: ${requestId})` : message);
this.name = "ApiClientError";
this.requestId = requestId;
}
}
type LegacyApiResponse<T> =
| {
success: true;
data?: T;
message?: string;
}
| {
success: false;
error?: string;
message?: string;
details?: unknown;
};
function normalizeApiResponse<T>(
payload: unknown,
): ApiResponse<T> | LegacyApiResponse<T> {
if (!payload || typeof payload !== "object") {
throw new ApiClientError("API request failed: malformed JSON response");
}
const response = payload as Record<string, unknown>;
if (typeof response.ok === "boolean") {
return payload as ApiResponse<T>;
}
if (typeof response.success === "boolean") {
return payload as LegacyApiResponse<T>;
}
throw new ApiClientError("API request failed: unexpected response shape");
}
async function fetchApi<T>( async function fetchApi<T>(
endpoint: string, endpoint: string,
options?: RequestInit, options?: RequestInit,
@ -49,22 +88,38 @@ async function fetchApi<T>(
const text = await response.text(); const text = await response.text();
let data: ApiResponse<T>; let payload: unknown;
try { try {
data = JSON.parse(text); payload = JSON.parse(text);
} catch { } catch {
// If the response is not JSON, it's likely an HTML error page // If the response is not JSON, it's likely an HTML error page
console.error("API returned non-JSON response:", text.substring(0, 500)); console.error("API returned non-JSON response:", text.substring(0, 500));
throw new Error( throw new ApiClientError(
`Server error (${response.status}): Expected JSON but received HTML. Is the backend server running?`, `Server error (${response.status}): Expected JSON but received HTML. Is the backend server running?`,
); );
} }
const parsed = normalizeApiResponse<T>(payload);
if (!data.success) { if ("ok" in parsed) {
throw new Error(data.error || "API request failed"); if (!parsed.ok) {
throw new ApiClientError(
parsed.error.message || "API request failed",
parsed.meta?.requestId,
);
}
return parsed.data as T;
} }
return data.data as T; if (!parsed.success) {
throw new ApiClientError(
parsed.error || parsed.message || "API request failed",
);
}
const data = parsed.data;
if (data !== undefined) return data as T;
if (parsed.message !== undefined) return { message: parsed.message } as T;
return null as T;
} }
// Jobs API // Jobs API

View File

@ -23,7 +23,7 @@ describe.sequential("Backup API routes", () => {
const body = await res.json(); const body = await res.json();
expect(res.status).toBe(200); expect(res.status).toBe(200);
expect(body.success).toBe(true); expect(body.ok).toBe(true);
expect(body.data.backups).toEqual([]); expect(body.data.backups).toEqual([]);
expect(body.data.nextScheduled).toBeNull(); expect(body.data.nextScheduled).toBeNull();
}); });
@ -36,7 +36,7 @@ describe.sequential("Backup API routes", () => {
const body = await res.json(); const body = await res.json();
expect(res.status).toBe(200); expect(res.status).toBe(200);
expect(body.success).toBe(true); expect(body.ok).toBe(true);
expect(body.data.backups).toHaveLength(1); expect(body.data.backups).toHaveLength(1);
expect(body.data.backups[0]).toHaveProperty("filename"); expect(body.data.backups[0]).toHaveProperty("filename");
expect(body.data.backups[0]).toHaveProperty("type", "manual"); expect(body.data.backups[0]).toHaveProperty("type", "manual");
@ -51,7 +51,7 @@ describe.sequential("Backup API routes", () => {
const body = await res.json(); const body = await res.json();
expect(res.status).toBe(200); expect(res.status).toBe(200);
expect(body.success).toBe(true); expect(body.ok).toBe(true);
expect(body.data.type).toBe("manual"); expect(body.data.type).toBe("manual");
expect(body.data.filename).toMatch( expect(body.data.filename).toMatch(
/^jobs_manual_\d{4}_\d{2}_\d{2}_\d{2}_\d{2}_\d{2}\.db$/, /^jobs_manual_\d{4}_\d{2}_\d{2}_\d{2}_\d{2}_\d{2}\.db$/,
@ -67,8 +67,8 @@ describe.sequential("Backup API routes", () => {
const body = await res.json(); const body = await res.json();
expect(res.status).toBe(500); expect(res.status).toBe(500);
expect(body.success).toBe(false); expect(body.ok).toBe(false);
expect(body.error).toContain("Database file not found"); expect(body.error.message).toContain("Database file not found");
}); });
}); });
@ -88,8 +88,8 @@ describe.sequential("Backup API routes", () => {
const deleteBody = await deleteRes.json(); const deleteBody = await deleteRes.json();
expect(deleteRes.status).toBe(200); expect(deleteRes.status).toBe(200);
expect(deleteBody.success).toBe(true); expect(deleteBody.ok).toBe(true);
expect(deleteBody.message).toContain("deleted successfully"); expect(deleteBody.data.message).toContain("deleted successfully");
// Verify it's gone // Verify it's gone
const listRes = await fetch(`${baseUrl}/api/backups`); const listRes = await fetch(`${baseUrl}/api/backups`);
@ -104,8 +104,8 @@ describe.sequential("Backup API routes", () => {
const body = await res.json(); const body = await res.json();
expect(res.status).toBe(404); expect(res.status).toBe(404);
expect(body.success).toBe(false); expect(body.ok).toBe(false);
expect(body.error).toContain("not found"); expect(body.error.message).toContain("not found");
}); });
it("should return 400 for invalid filename", async () => { it("should return 400 for invalid filename", async () => {
@ -115,8 +115,8 @@ describe.sequential("Backup API routes", () => {
const body = await res.json(); const body = await res.json();
expect(res.status).toBe(400); expect(res.status).toBe(400);
expect(body.success).toBe(false); expect(body.ok).toBe(false);
expect(body.error).toContain("Invalid"); expect(body.error.message).toContain("Invalid");
}); });
}); });
}); });

View File

@ -1,3 +1,4 @@
import { logger } from "@infra/logger";
import { import {
createBackup, createBackup,
deleteBackup, deleteBackup,
@ -25,7 +26,7 @@ backupRouter.get("/", async (_req: Request, res: Response) => {
}); });
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : "Unknown error"; const message = error instanceof Error ? error.message : "Unknown error";
console.error("❌ [backup-api] Failed to list backups:", error); logger.error("Failed to list backups", error);
res.status(500).json({ success: false, error: message }); res.status(500).json({ success: false, error: message });
} }
}); });
@ -49,7 +50,7 @@ backupRouter.post("/", async (_req: Request, res: Response) => {
}); });
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : "Unknown error"; const message = error instanceof Error ? error.message : "Unknown error";
console.error("❌ [backup-api] Failed to create backup:", error); logger.error("Failed to create backup", error);
res.status(500).json({ success: false, error: message }); res.status(500).json({ success: false, error: message });
} }
}); });
@ -77,10 +78,10 @@ backupRouter.delete("/:filename", async (req: Request, res: Response) => {
}); });
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : "Unknown error"; const message = error instanceof Error ? error.message : "Unknown error";
console.error( logger.error("Failed to delete backup", {
`❌ [backup-api] Failed to delete backup ${req.params.filename}:`, filename: req.params.filename,
error, error,
); });
if (message.includes("not found")) { if (message.includes("not found")) {
res.status(404).json({ success: false, error: message }); res.status(404).json({ success: false, error: message });

View File

@ -28,7 +28,7 @@ describe.sequential("Database API routes", () => {
const res = await fetch(`${baseUrl}/api/database`, { method: "DELETE" }); const res = await fetch(`${baseUrl}/api/database`, { method: "DELETE" });
const body = await res.json(); const body = await res.json();
expect(body.success).toBe(true); expect(body.ok).toBe(true);
expect(body.data.jobsDeleted).toBe(1); expect(body.data.jobsDeleted).toBe(1);
}); });
}); });

View File

@ -28,7 +28,7 @@ describe.sequential("Jobs API routes", () => {
const listRes = await fetch(`${baseUrl}/api/jobs`); const listRes = await fetch(`${baseUrl}/api/jobs`);
const listBody = await listRes.json(); const listBody = await listRes.json();
expect(listBody.success).toBe(true); expect(listBody.ok).toBe(true);
expect(listBody.data.total).toBe(1); expect(listBody.data.total).toBe(1);
expect(listBody.data.jobs[0].id).toBe(job.id); expect(listBody.data.jobs[0].id).toBe(job.id);
@ -92,7 +92,7 @@ describe.sequential("Jobs API routes", () => {
method: "POST", method: "POST",
}); });
const body = await res.json(); const body = await res.json();
expect(body.success).toBe(true); expect(body.ok).toBe(true);
expect(body.data.status).toBe("applied"); expect(body.data.status).toBe("applied");
expect(body.data.notionPageId).toBe("page-123"); expect(body.data.notionPageId).toBe("page-123");
expect(body.data.appliedAt).toBeTruthy(); expect(body.data.appliedAt).toBeTruthy();
@ -135,7 +135,7 @@ describe.sequential("Jobs API routes", () => {
}); });
const body = await res.json(); const body = await res.json();
expect(body.success).toBe(true); expect(body.ok).toBe(true);
expect(body.data.suitabilityScore).toBe(77); expect(body.data.suitabilityScore).toBe(77);
expect(body.data.suitabilityReason).toBe("Updated fit"); expect(body.data.suitabilityReason).toBe("Updated fit");
}); });
@ -165,7 +165,7 @@ describe.sequential("Jobs API routes", () => {
}); });
const body = await res.json(); const body = await res.json();
expect(body.success).toBe(true); expect(body.ok).toBe(true);
expect(body.data.sponsorMatchScore).toBe(100); expect(body.data.sponsorMatchScore).toBe(100);
expect(body.data.sponsorMatchNames).toContain("ACME CORP SPONSOR"); expect(body.data.sponsorMatchNames).toContain("ACME CORP SPONSOR");
}); });
@ -192,7 +192,7 @@ describe.sequential("Jobs API routes", () => {
body: JSON.stringify({ toStage: "applied" }), body: JSON.stringify({ toStage: "applied" }),
}); });
const body1 = await trans1.json(); const body1 = await trans1.json();
expect(body1.success).toBe(true); expect(body1.ok).toBe(true);
expect(body1.data.toStage).toBe("applied"); expect(body1.data.toStage).toBe("applied");
const eventId = body1.data.id; const eventId = body1.data.id;
@ -209,7 +209,7 @@ describe.sequential("Jobs API routes", () => {
// 3. Get events // 3. Get events
const eventsRes = await fetch(`${baseUrl}/api/jobs/${jobId}/events`); const eventsRes = await fetch(`${baseUrl}/api/jobs/${jobId}/events`);
const eventsBody = await eventsRes.json(); const eventsBody = await eventsRes.json();
expect(eventsBody.success).toBe(true); expect(eventsBody.ok).toBe(true);
expect(eventsBody.data).toHaveLength(2); expect(eventsBody.data).toHaveLength(2);
expect(eventsBody.data[0].toStage).toBe("applied"); expect(eventsBody.data[0].toStage).toBe("applied");
expect(eventsBody.data[1].toStage).toBe("recruiter_screen"); expect(eventsBody.data[1].toStage).toBe("recruiter_screen");
@ -252,7 +252,7 @@ describe.sequential("Jobs API routes", () => {
// 1. Initial state // 1. Initial state
const res1 = await fetch(`${baseUrl}/api/jobs/${jobId}/tasks`); const res1 = await fetch(`${baseUrl}/api/jobs/${jobId}/tasks`);
const body1 = await res1.json(); const body1 = await res1.json();
expect(body1.success).toBe(true); expect(body1.ok).toBe(true);
expect(body1.data).toEqual([]); expect(body1.data).toEqual([]);
// 2. Insert a task // 2. Insert a task
@ -297,7 +297,7 @@ describe.sequential("Jobs API routes", () => {
body: JSON.stringify({ outcome: "rejected" }), body: JSON.stringify({ outcome: "rejected" }),
}); });
const body = await res.json(); const body = await res.json();
expect(body.success).toBe(true); expect(body.ok).toBe(true);
expect(body.data.outcome).toBe("rejected"); expect(body.data.outcome).toBe("rejected");
expect(body.data.closedAt).toBeTruthy(); expect(body.data.closedAt).toBeTruthy();
}); });

View File

@ -1,3 +1,5 @@
import { logger } from "@infra/logger";
import { sanitizeWebhookPayload } from "@infra/sanitize";
import { import {
APPLICATION_OUTCOMES, APPLICATION_OUTCOMES,
APPLICATION_STAGES, APPLICATION_STAGES,
@ -8,7 +10,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 { import {
generateFinalPdf, generateFinalPdf,
processJob, processJob,
@ -49,23 +50,35 @@ async function notifyJobCompleteWebhook(job: Job) {
const secret = process.env.WEBHOOK_SECRET; const secret = process.env.WEBHOOK_SECRET;
if (secret) headers.Authorization = `Bearer ${secret}`; if (secret) headers.Authorization = `Bearer ${secret}`;
const payload = sanitizeWebhookPayload({
event: "job.completed",
sentAt: new Date().toISOString(),
job: {
id: job.id,
source: job.source,
title: job.title,
employer: job.employer,
status: job.status,
suitabilityScore: job.suitabilityScore,
sponsorMatchScore: job.sponsorMatchScore,
},
});
const response = await fetch(webhookUrl, { const response = await fetch(webhookUrl, {
method: "POST", method: "POST",
headers, headers,
body: JSON.stringify({ body: JSON.stringify(payload),
event: "job.completed",
sentAt: new Date().toISOString(),
job,
}),
}); });
if (!response.ok) { if (!response.ok) {
console.warn( logger.warn("Job complete webhook POST failed", {
`ƒsÿ,? Job complete webhook POST failed (${response.status}): ${await response.text()}`, status: response.status,
); response: (await response.text().catch(() => "")).slice(0, 200),
jobId: job.id,
});
} }
} catch (error) { } catch (error) {
console.warn("ƒsÿ,? Job complete webhook POST failed:", error); logger.warn("Job complete webhook POST failed", { jobId: job.id, error });
} }
} }
@ -129,7 +142,7 @@ jobsRouter.get("/", async (req: Request, res: Response) => {
const stats = await jobsRepo.getJobStats(); const stats = await jobsRepo.getJobStats();
const response: ApiResponse<JobsListResponse> = { const response: ApiResponse<JobsListResponse> = {
success: true, ok: true,
data: { data: {
jobs, jobs,
total: jobs.length, total: jobs.length,
@ -493,7 +506,9 @@ jobsRouter.post("/:id/apply", async (req: Request, res: Response) => {
}); });
if (updatedJob) { if (updatedJob) {
notifyJobCompleteWebhook(updatedJob).catch(console.warn); notifyJobCompleteWebhook(updatedJob).catch((error) => {
logger.warn("Job complete webhook dispatch failed", error);
});
} }
if (!updatedJob) { if (!updatedJob) {

View File

@ -58,7 +58,7 @@ describe.sequential("Manual jobs API routes", () => {
body: JSON.stringify({ jobDescription: "Role description" }), body: JSON.stringify({ jobDescription: "Role description" }),
}); });
const body = await res.json(); const body = await res.json();
expect(body.success).toBe(true); expect(body.ok).toBe(true);
expect(body.data.job.title).toBe("Backend Engineer"); expect(body.data.job.title).toBe("Backend Engineer");
}); });
@ -81,7 +81,7 @@ describe.sequential("Manual jobs API routes", () => {
}), }),
}); });
const body = await res.json(); const body = await res.json();
expect(body.success).toBe(true); expect(body.ok).toBe(true);
expect(body.data.source).toBe("manual"); expect(body.data.source).toBe("manual");
expect(body.data.jobUrl).toMatch(/^manual:\/\//); expect(body.data.jobUrl).toMatch(/^manual:\/\//);
await new Promise((resolve) => setTimeout(resolve, 25)); await new Promise((resolve) => setTimeout(resolve, 25));

View File

@ -1,4 +1,5 @@
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { logger } from "@infra/logger";
import type { import type {
ApiResponse, ApiResponse,
ManualJobFetchResponse, ManualJobFetchResponse,
@ -154,7 +155,7 @@ manualJobsRouter.post("/fetch", async (req: Request, res: Response) => {
} }
const result: ApiResponse<ManualJobFetchResponse> = { const result: ApiResponse<ManualJobFetchResponse> = {
success: true, ok: true,
data: { data: {
content: enrichedContent, content: enrichedContent,
url: input.url, url: input.url,
@ -185,7 +186,7 @@ manualJobsRouter.post("/infer", async (req: Request, res: Response) => {
const result = await inferManualJobDetails(input.jobDescription); const result = await inferManualJobDetails(input.jobDescription);
const response: ApiResponse<ManualJobInferenceResponse> = { const response: ApiResponse<ManualJobInferenceResponse> = {
success: true, ok: true,
data: { data: {
job: result.job, job: result.job,
warning: result.warning ?? null, warning: result.warning ?? null,
@ -254,10 +255,10 @@ manualJobsRouter.post("/import", async (req: Request, res: Response) => {
suitabilityReason: reason, suitabilityReason: reason,
}); });
} catch (error) { } catch (error) {
console.warn("Manual job scoring failed:", error); logger.warn("Manual job scoring failed", error);
} }
})().catch((error) => { })().catch((error) => {
console.warn("Manual job scoring task failed to start:", error); logger.warn("Manual job scoring task failed to start", error);
}); });
res.json({ success: true, data: createdJob }); res.json({ success: true, data: createdJob });

View File

@ -30,7 +30,7 @@ describe.sequential("Onboarding API routes", () => {
const body = await res.json(); const body = await res.json();
expect(res.ok).toBe(true); expect(res.ok).toBe(true);
expect(body.success).toBe(true); expect(body.ok).toBe(true);
expect(body.data.valid).toBe(false); expect(body.data.valid).toBe(false);
expect(body.data.message).toContain("missing"); expect(body.data.message).toContain("missing");
}); });
@ -68,7 +68,7 @@ describe.sequential("Onboarding API routes", () => {
const body = await res.json(); const body = await res.json();
expect(res.ok).toBe(true); expect(res.ok).toBe(true);
expect(body.success).toBe(true); expect(body.ok).toBe(true);
// Should be invalid because the key is fake // Should be invalid because the key is fake
expect(body.data.valid).toBe(false); expect(body.data.valid).toBe(false);
}); });
@ -84,7 +84,7 @@ describe.sequential("Onboarding API routes", () => {
const body = await res.json(); const body = await res.json();
expect(res.ok).toBe(true); expect(res.ok).toBe(true);
expect(body.success).toBe(true); expect(body.ok).toBe(true);
expect(body.data.valid).toBe(false); expect(body.data.valid).toBe(false);
expect(body.data.message).toContain("missing"); expect(body.data.message).toContain("missing");
}); });
@ -132,7 +132,7 @@ describe.sequential("Onboarding API routes", () => {
const body = await res.json(); const body = await res.json();
expect(res.ok).toBe(true); expect(res.ok).toBe(true);
expect(body.success).toBe(true); expect(body.ok).toBe(true);
// Should be invalid because credentials are fake // Should be invalid because credentials are fake
expect(body.data.valid).toBe(false); expect(body.data.valid).toBe(false);
}); });
@ -157,7 +157,7 @@ describe.sequential("Onboarding API routes", () => {
const body = await res.json(); const body = await res.json();
expect(res.ok).toBe(true); expect(res.ok).toBe(true);
expect(body.success).toBe(true); expect(body.ok).toBe(true);
expect(body.data.valid).toBe(false); expect(body.data.valid).toBe(false);
expect(body.data.message).toContain("No base resume selected"); expect(body.data.message).toContain("No base resume selected");
}); });

View File

@ -19,7 +19,7 @@ describe.sequential("Pipeline API routes", () => {
it("reports pipeline status", async () => { it("reports pipeline status", async () => {
const res = await fetch(`${baseUrl}/api/pipeline/status`); const res = await fetch(`${baseUrl}/api/pipeline/status`);
const body = await res.json(); const body = await res.json();
expect(body.success).toBe(true); expect(body.ok).toBe(true);
expect(body.data.isRunning).toBe(false); expect(body.data.isRunning).toBe(false);
expect(body.data.lastRun).toBeNull(); expect(body.data.lastRun).toBeNull();
}); });
@ -39,7 +39,7 @@ describe.sequential("Pipeline API routes", () => {
body: JSON.stringify({ topN: 5, sources: ["gradcracker"] }), body: JSON.stringify({ topN: 5, sources: ["gradcracker"] }),
}); });
const runBody = await runRes.json(); const runBody = await runRes.json();
expect(runBody.success).toBe(true); expect(runBody.ok).toBe(true);
expect(runPipeline).toHaveBeenCalledWith({ expect(runPipeline).toHaveBeenCalledWith({
topN: 5, topN: 5,
sources: ["gradcracker"], sources: ["gradcracker"],

View File

@ -1,3 +1,5 @@
import { logger } from "@infra/logger";
import { runWithRequestContext } from "@infra/request-context";
import type { ApiResponse, PipelineStatusResponse } from "@shared/types"; import type { ApiResponse, PipelineStatusResponse } 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";
@ -19,7 +21,7 @@ pipelineRouter.get("/status", async (_req: Request, res: Response) => {
const lastRun = await pipelineRepo.getLatestPipelineRun(); const lastRun = await pipelineRepo.getLatestPipelineRun();
const response: ApiResponse<PipelineStatusResponse> = { const response: ApiResponse<PipelineStatusResponse> = {
success: true, ok: true,
data: { data: {
isRunning, isRunning,
lastRun, lastRun,
@ -30,7 +32,9 @@ pipelineRouter.get("/status", async (_req: Request, res: Response) => {
res.json(response); res.json(response);
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : "Unknown error"; const message = error instanceof Error ? error.message : "Unknown error";
res.status(500).json({ success: false, error: message }); res
.status(500)
.json({ ok: false, error: { code: "INTERNAL_ERROR", message } });
} }
}); });
@ -70,10 +74,12 @@ pipelineRouter.get("/progress", (req: Request, res: Response) => {
pipelineRouter.get("/runs", async (_req: Request, res: Response) => { pipelineRouter.get("/runs", async (_req: Request, res: Response) => {
try { try {
const runs = await pipelineRepo.getRecentPipelineRuns(20); const runs = await pipelineRepo.getRecentPipelineRuns(20);
res.json({ success: true, data: runs }); res.json({ ok: true, data: runs });
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : "Unknown error"; const message = error instanceof Error ? error.message : "Unknown error";
res.status(500).json({ success: false, error: message }); res
.status(500)
.json({ ok: false, error: { code: "INTERNAL_ERROR", message } });
} }
}); });
@ -94,17 +100,26 @@ pipelineRouter.post("/run", async (req: Request, res: Response) => {
const config = runPipelineSchema.parse(req.body); const config = runPipelineSchema.parse(req.body);
// Start pipeline in background // Start pipeline in background
runPipeline(config).catch(console.error); runWithRequestContext({}, () => {
runPipeline(config).catch((error) => {
logger.error("Background pipeline run failed", error);
});
});
res.json({ res.json({
success: true, ok: true,
data: { message: "Pipeline started" }, data: { message: "Pipeline started" },
}); });
} catch (error) { } catch (error) {
if (error instanceof z.ZodError) { if (error instanceof z.ZodError) {
return res.status(400).json({ success: false, error: error.message }); return res.status(400).json({
ok: false,
error: { code: "INVALID_REQUEST", message: error.message },
});
} }
const message = error instanceof Error ? error.message : "Unknown error"; const message = error instanceof Error ? error.message : "Unknown error";
res.status(500).json({ success: false, error: message }); res
.status(500)
.json({ ok: false, error: { code: "INTERNAL_ERROR", message } });
} }
}); });

View File

@ -83,7 +83,7 @@ describe.sequential("Profile API routes", () => {
const body = await res.json(); const body = await res.json();
expect(res.ok).toBe(true); expect(res.ok).toBe(true);
expect(body.success).toBe(true); expect(body.ok).toBe(true);
expect(Array.isArray(body.data)).toBe(true); expect(Array.isArray(body.data)).toBe(true);
expect(body.data.length).toBe(2); expect(body.data.length).toBe(2);
}); });
@ -97,8 +97,8 @@ describe.sequential("Profile API routes", () => {
const body = await res.json(); const body = await res.json();
expect(res.ok).toBe(false); expect(res.ok).toBe(false);
expect(body.success).toBe(false); expect(body.ok).toBe(false);
expect(body.error).toContain("Base resume not configured"); expect(body.error.message).toContain("Base resume not configured");
}); });
}); });
@ -114,7 +114,7 @@ describe.sequential("Profile API routes", () => {
const body = await res.json(); const body = await res.json();
expect(res.ok).toBe(true); expect(res.ok).toBe(true);
expect(body.success).toBe(true); expect(body.ok).toBe(true);
expect(body.data).toEqual(mockProfile); expect(body.data).toEqual(mockProfile);
}); });
@ -127,8 +127,8 @@ describe.sequential("Profile API routes", () => {
const body = await res.json(); const body = await res.json();
expect(res.ok).toBe(false); expect(res.ok).toBe(false);
expect(body.success).toBe(false); expect(body.ok).toBe(false);
expect(body.error).toContain("Base resume not configured"); expect(body.error.message).toContain("Base resume not configured");
}); });
}); });
@ -140,7 +140,7 @@ describe.sequential("Profile API routes", () => {
const body = await res.json(); const body = await res.json();
expect(res.ok).toBe(true); expect(res.ok).toBe(true);
expect(body.success).toBe(true); expect(body.ok).toBe(true);
expect(body.data.exists).toBe(false); expect(body.data.exists).toBe(false);
expect(body.data.error).toContain("No base resume selected"); expect(body.data.error).toContain("No base resume selected");
}); });
@ -156,7 +156,7 @@ describe.sequential("Profile API routes", () => {
const body = await res.json(); const body = await res.json();
expect(res.ok).toBe(true); expect(res.ok).toBe(true);
expect(body.success).toBe(true); expect(body.ok).toBe(true);
expect(body.data.exists).toBe(true); expect(body.data.exists).toBe(true);
expect(body.data.error).toBeNull(); expect(body.data.error).toBeNull();
}); });
@ -169,7 +169,7 @@ describe.sequential("Profile API routes", () => {
const body = await res.json(); const body = await res.json();
expect(res.ok).toBe(true); expect(res.ok).toBe(true);
expect(body.success).toBe(true); expect(body.ok).toBe(true);
expect(body.data.exists).toBe(false); expect(body.data.exists).toBe(false);
expect(body.data.error).toContain("credentials not configured"); expect(body.data.error).toContain("credentials not configured");
}); });
@ -185,7 +185,7 @@ describe.sequential("Profile API routes", () => {
const body = await res.json(); const body = await res.json();
expect(res.ok).toBe(true); expect(res.ok).toBe(true);
expect(body.success).toBe(true); expect(body.ok).toBe(true);
expect(body.data.exists).toBe(false); expect(body.data.exists).toBe(false);
expect(body.data.error).toContain("empty or invalid"); expect(body.data.error).toContain("empty or invalid");
}); });

View File

@ -24,7 +24,7 @@ describe.sequential("Settings API routes", () => {
it("returns settings with defaults", async () => { it("returns settings with defaults", async () => {
const res = await fetch(`${baseUrl}/api/settings`); const res = await fetch(`${baseUrl}/api/settings`);
const body = await res.json(); const body = await res.json();
expect(body.success).toBe(true); expect(body.ok).toBe(true);
expect(body.data.defaultModel).toBe("test-model"); expect(body.data.defaultModel).toBe("test-model");
expect(Array.isArray(body.data.searchTerms)).toBe(true); expect(Array.isArray(body.data.searchTerms)).toBe(true);
expect(body.data.rxresumeEmail).toBe("resume@example.com"); expect(body.data.rxresumeEmail).toBe("resume@example.com");
@ -51,7 +51,7 @@ describe.sequential("Settings API routes", () => {
}), }),
}); });
const patchBody = await patchRes.json(); const patchBody = await patchRes.json();
expect(patchBody.success).toBe(true); expect(patchBody.ok).toBe(true);
expect(patchBody.data.searchTerms).toEqual(["engineer"]); expect(patchBody.data.searchTerms).toEqual(["engineer"]);
expect(patchBody.data.overrideSearchTerms).toEqual(["engineer"]); expect(patchBody.data.overrideSearchTerms).toEqual(["engineer"]);
expect(patchBody.data.rxresumeEmail).toBe("updated@example.com"); expect(patchBody.data.rxresumeEmail).toBe("updated@example.com");
@ -70,7 +70,7 @@ describe.sequential("Settings API routes", () => {
}); });
expect(res.status).toBe(400); expect(res.status).toBe(400);
const body = await res.json(); const body = await res.json();
expect(body.success).toBe(false); expect(body.ok).toBe(false);
expect(body.error).toContain("Username is required"); expect(body.error.message).toContain("Username is required");
}); });
}); });

View File

@ -1,3 +1,4 @@
import { logger } from "@infra/logger";
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 {
@ -72,7 +73,7 @@ settingsRouter.get("/rx-resumes", async (_req: Request, res: Response) => {
return; return;
} }
const message = error instanceof Error ? error.message : "Unknown error"; const message = error instanceof Error ? error.message : "Unknown error";
console.error(`❌ Failed to fetch Reactive Resumes: ${message}`); logger.error("Failed to fetch Reactive Resumes", { message });
res.status(500).json({ success: false, error: message }); res.status(500).json({ success: false, error: message });
} }
}); });
@ -103,7 +104,7 @@ settingsRouter.get(
return; return;
} }
const message = error instanceof Error ? error.message : "Unknown error"; const message = error instanceof Error ? error.message : "Unknown error";
console.error(`❌ Failed to fetch RxResume projects: ${message}`); logger.error("Failed to fetch RxResume projects", { message });
res.status(500).json({ success: false, error: message }); res.status(500).json({ success: false, error: message });
} }
}, },

View File

@ -47,7 +47,7 @@ describe.sequential("UK Visa Jobs API routes", () => {
body: JSON.stringify({ query: "engineer" }), body: JSON.stringify({ query: "engineer" }),
}); });
const body = await res.json(); const body = await res.json();
expect(body.success).toBe(true); expect(body.ok).toBe(true);
expect(body.data.totalPages).toBe(2); expect(body.data.totalPages).toBe(2);
expect(fetchUkVisaJobsPage).toHaveBeenCalledWith({ expect(fetchUkVisaJobsPage).toHaveBeenCalledWith({
searchKeyword: "engineer", searchKeyword: "engineer",
@ -87,7 +87,7 @@ describe.sequential("UK Visa Jobs API routes", () => {
}), }),
}); });
const body = await res.json(); const body = await res.json();
expect(body.success).toBe(true); expect(body.ok).toBe(true);
expect(body.data.created).toBe(1); expect(body.data.created).toBe(1);
expect(body.data.skipped).toBe(1); expect(body.data.skipped).toBe(1);
}); });

View File

@ -70,7 +70,7 @@ ukVisaJobsRouter.post("/search", async (req: Request, res: Response) => {
); );
const response: ApiResponse<UkVisaJobsSearchResponse> = { const response: ApiResponse<UkVisaJobsSearchResponse> = {
success: true, ok: true,
data: { data: {
jobs: result.jobs, jobs: result.jobs,
totalJobs: result.totalJobs, totalJobs: result.totalJobs,
@ -133,7 +133,7 @@ ukVisaJobsRouter.post("/import", async (req: Request, res: Response) => {
const result = await jobsRepo.bulkCreateJobs(jobs); const result = await jobsRepo.bulkCreateJobs(jobs);
const response: ApiResponse<UkVisaJobsImportResponse> = { const response: ApiResponse<UkVisaJobsImportResponse> = {
success: true, ok: true,
data: { data: {
created: result.created, created: result.created,
skipped: result.skipped, skipped: result.skipped,

View File

@ -35,7 +35,7 @@ describe.sequential("Visa sponsors API routes", () => {
const statusRes = await fetch(`${baseUrl}/api/visa-sponsors/status`); const statusRes = await fetch(`${baseUrl}/api/visa-sponsors/status`);
const statusBody = await statusRes.json(); const statusBody = await statusRes.json();
expect(statusBody.success).toBe(true); expect(statusBody.ok).toBe(true);
expect(statusBody.data.totalSponsors).toBe(0); expect(statusBody.data.totalSponsors).toBe(0);
const updateRes = await fetch(`${baseUrl}/api/visa-sponsors/update`, { const updateRes = await fetch(`${baseUrl}/api/visa-sponsors/update`, {
@ -76,7 +76,7 @@ describe.sequential("Visa sponsors API routes", () => {
body: JSON.stringify({ query: "Acme" }), body: JSON.stringify({ query: "Acme" }),
}); });
const body = await res.json(); const body = await res.json();
expect(body.success).toBe(true); expect(body.ok).toBe(true);
expect(body.data.total).toBe(1); expect(body.data.total).toBe(1);
const orgRes = await fetch( const orgRes = await fetch(

View File

@ -17,7 +17,7 @@ visaSponsorsRouter.get("/status", async (_req: Request, res: Response) => {
try { try {
const status = visaSponsors.getStatus(); const status = visaSponsors.getStatus();
const response: ApiResponse<VisaSponsorStatusResponse> = { const response: ApiResponse<VisaSponsorStatusResponse> = {
success: true, ok: true,
data: status, data: status,
}; };
res.json(response); res.json(response);
@ -46,7 +46,7 @@ visaSponsorsRouter.post("/search", async (req: Request, res: Response) => {
}); });
const response: ApiResponse<VisaSponsorSearchResponse> = { const response: ApiResponse<VisaSponsorSearchResponse> = {
success: true, ok: true,
data: { data: {
results, results,
query: input.query, query: input.query,

View File

@ -29,7 +29,7 @@ describe.sequential("Webhook API routes", () => {
headers: { Authorization: "Bearer secret" }, headers: { Authorization: "Bearer secret" },
}); });
const goodBody = await goodRes.json(); const goodBody = await goodRes.json();
expect(goodBody.success).toBe(true); expect(goodBody.ok).toBe(true);
expect(goodBody.data.message).toBe("Pipeline triggered"); expect(goodBody.data.message).toBe("Pipeline triggered");
}); });
}); });

View File

@ -1,3 +1,5 @@
import { logger } from "@infra/logger";
import { runWithRequestContext } from "@infra/request-context";
import { type Request, type Response, Router } from "express"; import { type Request, type Response, Router } from "express";
import { runPipeline } from "../../pipeline/index"; import { runPipeline } from "../../pipeline/index";
@ -12,15 +14,22 @@ webhookRouter.post("/trigger", async (req: Request, res: Response) => {
const expectedToken = process.env.WEBHOOK_SECRET; const expectedToken = process.env.WEBHOOK_SECRET;
if (expectedToken && authHeader !== `Bearer ${expectedToken}`) { if (expectedToken && authHeader !== `Bearer ${expectedToken}`) {
return res.status(401).json({ success: false, error: "Unauthorized" }); return res.status(401).json({
ok: false,
error: { code: "UNAUTHORIZED", message: "Unauthorized" },
});
} }
try { try {
// Start pipeline in background // Start pipeline in background
runPipeline().catch(console.error); runWithRequestContext({}, () => {
runPipeline().catch((error) => {
logger.error("Webhook-triggered pipeline run failed", error);
});
});
res.json({ res.json({
success: true, ok: true,
data: { data: {
message: "Pipeline triggered", message: "Pipeline triggered",
triggeredAt: new Date().toISOString(), triggeredAt: new Date().toISOString(),
@ -28,6 +37,8 @@ webhookRouter.post("/trigger", async (req: Request, res: Response) => {
}); });
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : "Unknown error"; const message = error instanceof Error ? error.message : "Unknown error";
res.status(500).json({ success: false, error: message }); res
.status(500)
.json({ ok: false, error: { code: "INTERNAL_ERROR", message } });
} }
}); });

View File

@ -5,6 +5,15 @@
import { readFile } from "node:fs/promises"; import { readFile } from "node:fs/promises";
import { dirname, join } from "node:path"; import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { unauthorized } from "@infra/errors";
import {
apiErrorHandler,
fail,
legacyApiResponseShim,
notFoundApiHandler,
requestContextMiddleware,
} from "@infra/http";
import { logger } from "@infra/logger";
import cors from "cors"; import cors from "cors";
import express from "express"; import express from "express";
import { apiRouter } from "./api/index"; import { apiRouter } from "./api/index";
@ -67,7 +76,7 @@ function createBasicAuthGuard() {
if (!enabled || !requiresAuth(req.method, req.path)) return next(); if (!enabled || !requiresAuth(req.method, req.path)) return next();
if (isAuthorized(req)) return next(); if (isAuthorized(req)) return next();
res.setHeader("WWW-Authenticate", 'Basic realm="Job Ops"'); res.setHeader("WWW-Authenticate", 'Basic realm="Job Ops"');
res.status(401).send("Authentication required"); fail(res, unauthorized("Authentication required"));
}; };
return { return {
@ -82,16 +91,21 @@ export function createApp() {
const authGuard = createBasicAuthGuard(); const authGuard = createBasicAuthGuard();
app.use(cors()); app.use(cors());
app.use(requestContextMiddleware());
app.use(express.json({ limit: "5mb" })); app.use(express.json({ limit: "5mb" }));
app.use(legacyApiResponseShim());
// Logging middleware // Logging middleware
app.use((req, res, next) => { app.use((req, res, next) => {
const start = Date.now(); const start = Date.now();
res.on("finish", () => { res.on("finish", () => {
const duration = Date.now() - start; const duration = Date.now() - start;
console.log( logger.info("HTTP request completed", {
`${req.method} ${req.path} - ${res.statusCode} (${duration}ms)`, method: req.method,
); path: req.path,
status: res.statusCode,
durationMs: duration,
});
}); });
next(); next();
}); });
@ -101,6 +115,7 @@ export function createApp() {
// API routes // API routes
app.use("/api", apiRouter); app.use("/api", apiRouter);
app.use(notFoundApiHandler());
// Serve static files for generated PDFs // Serve static files for generated PDFs
const pdfDir = join(getDataDir(), "pdfs"); const pdfDir = join(getDataDir(), "pdfs");
@ -132,5 +147,7 @@ export function createApp() {
}); });
} }
app.use(apiErrorHandler);
return app; return app;
} }

View File

@ -0,0 +1,128 @@
import { ZodError } from "zod";
export type AppErrorCode =
| "INVALID_REQUEST"
| "UNAUTHORIZED"
| "FORBIDDEN"
| "NOT_FOUND"
| "REQUEST_TIMEOUT"
| "CONFLICT"
| "UNPROCESSABLE_ENTITY"
| "UPSTREAM_ERROR"
| "SERVICE_UNAVAILABLE"
| "INTERNAL_ERROR";
const DEFAULT_CODE_BY_STATUS: Record<number, AppErrorCode> = {
400: "INVALID_REQUEST",
401: "UNAUTHORIZED",
403: "FORBIDDEN",
404: "NOT_FOUND",
408: "REQUEST_TIMEOUT",
409: "CONFLICT",
422: "UNPROCESSABLE_ENTITY",
500: "INTERNAL_ERROR",
502: "UPSTREAM_ERROR",
503: "SERVICE_UNAVAILABLE",
};
export class AppError extends Error {
status: number;
code: AppErrorCode;
details?: unknown;
constructor(args: {
message: string;
status?: number;
code?: AppErrorCode;
details?: unknown;
cause?: unknown;
}) {
super(args.message, { cause: args.cause });
this.name = "AppError";
this.status = args.status ?? 500;
this.code = args.code ?? statusToCode(this.status);
this.details = args.details;
}
}
export function statusToCode(status: number): AppErrorCode {
return DEFAULT_CODE_BY_STATUS[status] ?? "INTERNAL_ERROR";
}
export function badRequest(message: string, details?: unknown): AppError {
return new AppError({
status: 400,
code: "INVALID_REQUEST",
message,
details,
});
}
export function unauthorized(message = "Unauthorized"): AppError {
return new AppError({ status: 401, code: "UNAUTHORIZED", message });
}
export function forbidden(message = "Forbidden"): AppError {
return new AppError({ status: 403, code: "FORBIDDEN", message });
}
export function notFound(message = "Not found"): AppError {
return new AppError({ status: 404, code: "NOT_FOUND", message });
}
export function requestTimeout(message = "Request timed out"): AppError {
return new AppError({ status: 408, code: "REQUEST_TIMEOUT", message });
}
export function conflict(message: string): AppError {
return new AppError({ status: 409, code: "CONFLICT", message });
}
export function unprocessableEntity(
message: string,
details?: unknown,
): AppError {
return new AppError({
status: 422,
code: "UNPROCESSABLE_ENTITY",
message,
details,
});
}
export function upstreamError(message: string, details?: unknown): AppError {
return new AppError({
status: 502,
code: "UPSTREAM_ERROR",
message,
details,
});
}
export function serviceUnavailable(message: string): AppError {
return new AppError({ status: 503, code: "SERVICE_UNAVAILABLE", message });
}
export function toAppError(error: unknown): AppError {
if (error instanceof AppError) return error;
if (error instanceof ZodError) {
return badRequest(error.message, error.flatten());
}
if (error instanceof Error && error.name === "AbortError") {
return requestTimeout("Request timed out");
}
if (error instanceof Error) {
return new AppError({
status: 500,
code: "INTERNAL_ERROR",
message: error.message || "Internal server error",
cause: error,
});
}
return new AppError({
status: 500,
code: "INTERNAL_ERROR",
message: "Internal server error",
details: error,
});
}

View File

@ -0,0 +1,154 @@
import crypto from "node:crypto";
import type { ApiResponse } from "@shared/types";
import type {
ErrorRequestHandler,
NextFunction,
Request,
RequestHandler,
Response,
} from "express";
import type { AppError } from "./errors";
import { notFound, statusToCode, toAppError } from "./errors";
import { logger } from "./logger";
import { getRequestId, runWithRequestContext } from "./request-context";
import { sanitizeUnknown } from "./sanitize";
function getResponseRequestId(res: Response): string {
return (
(res.getHeader("x-request-id") as string | undefined) ??
getRequestId() ??
"unknown"
);
}
export function ok<T>(res: Response, data: T, status = 200): void {
const payload: ApiResponse<T> = {
ok: true,
data,
meta: { requestId: getResponseRequestId(res) },
};
res.status(status).json(payload);
}
export function fail(res: Response, error: AppError): void {
const payload: ApiResponse<never> = {
ok: false,
error: {
code: error.code,
message: error.message,
...(error.details !== undefined
? { details: sanitizeUnknown(error.details) }
: {}),
},
meta: { requestId: getResponseRequestId(res) },
};
res.status(error.status).json(payload);
}
export function asyncRoute(
handler: (
req: Request,
res: Response,
next: NextFunction,
) => Promise<unknown>,
): RequestHandler {
return (req, res, next) => {
Promise.resolve(handler(req, res, next)).catch(next);
};
}
export function requestContextMiddleware(): RequestHandler {
return (req, res, next) => {
const requestIdHeader = req.header("x-request-id")?.trim();
const requestId =
requestIdHeader && requestIdHeader.length > 0
? requestIdHeader
: crypto.randomUUID();
res.setHeader("x-request-id", requestId);
runWithRequestContext({ requestId }, () => next());
};
}
export function legacyApiResponseShim(): RequestHandler {
return (req, res, next) => {
if (!req.path.startsWith("/api")) return next();
const originalJson = res.json.bind(res);
res.json = ((body: unknown) => {
if (!body || typeof body !== "object") return originalJson(body);
const payload = body as Record<string, unknown>;
if ("ok" in payload) {
if (!("meta" in payload)) {
return originalJson({
...payload,
meta: { requestId: getResponseRequestId(res) },
});
}
return originalJson(body);
}
if (typeof payload.success === "boolean") {
const requestId = getResponseRequestId(res);
if (payload.success) {
let data: unknown = payload.data;
if (data === undefined && payload.message !== undefined) {
data = { message: payload.message };
}
return originalJson({
ok: true,
data: data ?? null,
meta: { requestId },
} satisfies ApiResponse<unknown>);
}
const status = res.statusCode >= 400 ? res.statusCode : 500;
const rawError = payload.error;
const message =
typeof rawError === "string"
? rawError
: typeof payload.message === "string"
? payload.message
: "Request failed";
const details =
rawError && typeof rawError === "object"
? (rawError as { details?: unknown }).details
: payload.details;
return originalJson({
ok: false,
error: {
code: statusToCode(status),
message,
...(details !== undefined
? { details: sanitizeUnknown(details) }
: {}),
},
meta: { requestId },
} satisfies ApiResponse<never>);
}
return originalJson(body);
}) as Response["json"];
next();
};
}
export function notFoundApiHandler(): RequestHandler {
return (req, _res, next) => {
if (!req.path.startsWith("/api")) return next();
next(notFound(`Route not found: ${req.method} ${req.path}`));
};
}
export const apiErrorHandler: ErrorRequestHandler = (err, _req, res, _next) => {
const appError = toAppError(err);
logger.error(appError.message, {
status: appError.status,
code: appError.code,
details: appError.details,
cause: appError.cause,
});
fail(res, appError);
};

View File

@ -0,0 +1,74 @@
import { getRequestContext } from "./request-context";
import { sanitizeError, sanitizeUnknown } from "./sanitize";
type LogLevel = "debug" | "info" | "warn" | "error";
const levelPriority: Record<LogLevel, number> = {
debug: 10,
info: 20,
warn: 30,
error: 40,
};
function resolveMinLevel(): LogLevel {
const raw = process.env.LOG_LEVEL?.toLowerCase();
if (raw === "debug" || raw === "info" || raw === "warn" || raw === "error") {
return raw;
}
return "info";
}
const minLevel = resolveMinLevel();
export class Logger {
constructor(private readonly context: Record<string, unknown> = {}) {}
child(context: Record<string, unknown>): Logger {
return new Logger({ ...this.context, ...context });
}
debug(message: string, meta?: unknown): void {
this.log("debug", message, meta);
}
info(message: string, meta?: unknown): void {
this.log("info", message, meta);
}
warn(message: string, meta?: unknown): void {
this.log("warn", message, meta);
}
error(message: string, meta?: unknown): void {
this.log("error", message, meta);
}
private log(level: LogLevel, message: string, meta?: unknown): void {
if (levelPriority[level] < levelPriority[minLevel]) return;
const requestContext = getRequestContext();
const payload: Record<string, unknown> = {
ts: new Date().toISOString(),
level,
msg: message,
...this.context,
...(requestContext ?? {}),
};
if (meta !== undefined) {
payload.meta =
meta instanceof Error ? sanitizeError(meta) : sanitizeUnknown(meta);
}
const line = JSON.stringify(payload);
if (level === "error") {
console.error(line);
} else if (level === "warn") {
console.warn(line);
} else {
console.log(line);
}
}
}
export const logger = new Logger();

View File

@ -0,0 +1,30 @@
import { AsyncLocalStorage } from "node:async_hooks";
export type RequestContext = {
requestId: string;
pipelineRunId?: string;
jobId?: string;
};
const storage = new AsyncLocalStorage<RequestContext>();
export function getRequestContext(): RequestContext | undefined {
return storage.getStore();
}
export function runWithRequestContext<T>(
context: Partial<RequestContext>,
fn: () => T,
): T {
const current = storage.getStore();
const merged: RequestContext = {
requestId: context.requestId ?? current?.requestId ?? "unknown",
...(current ?? {}),
...context,
};
return storage.run(merged, fn);
}
export function getRequestId(): string | undefined {
return storage.getStore()?.requestId;
}

View File

@ -0,0 +1,114 @@
const REDACTED = "[REDACTED]";
const SENSITIVE_KEY_PATTERN =
/(authorization|cookie|password|pass|secret|token|api.?key|credential|set-cookie|proxy-authorization|x-api-key)/i;
const DEFAULT_MAX_STRING = 800;
const DEFAULT_MAX_DEPTH = 5;
const DEFAULT_MAX_ITEMS = 30;
export function redactString(value: string, max = DEFAULT_MAX_STRING): string {
if (value.length <= max) return value;
return `${value.slice(0, max)}…(truncated ${value.length - max} chars)`;
}
export function sanitizeUnknown(
value: unknown,
options: { depth?: number; maxItems?: number; maxString?: number } = {},
): unknown {
const depth = options.depth ?? DEFAULT_MAX_DEPTH;
const maxItems = options.maxItems ?? DEFAULT_MAX_ITEMS;
const maxString = options.maxString ?? DEFAULT_MAX_STRING;
if (value === null || value === undefined) return value;
if (typeof value === "string") return redactString(value, maxString);
if (
typeof value === "number" ||
typeof value === "boolean" ||
typeof value === "bigint"
) {
return value;
}
if (value instanceof Error) {
return sanitizeError(value);
}
if (depth <= 0) {
return "[TRUNCATED_DEPTH]";
}
if (Array.isArray(value)) {
const limited = value.slice(0, maxItems);
const out = limited.map((item) =>
sanitizeUnknown(item, {
depth: depth - 1,
maxItems,
maxString,
}),
);
if (value.length > maxItems) {
out.push(`[TRUNCATED_ITEMS ${value.length - maxItems}]`);
}
return out;
}
if (typeof value === "object") {
const entries = Object.entries(value as Record<string, unknown>);
const out: Record<string, unknown> = {};
for (const [index, [key, entryValue]] of entries.entries()) {
if (index >= maxItems) {
out.__truncatedKeys = entries.length - maxItems;
break;
}
if (SENSITIVE_KEY_PATTERN.test(key)) {
out[key] = REDACTED;
continue;
}
out[key] = sanitizeUnknown(entryValue, {
depth: depth - 1,
maxItems,
maxString,
});
}
return out;
}
return String(value);
}
export function sanitizeError(error: Error): Record<string, unknown> {
const out: Record<string, unknown> = {
name: error.name,
message: redactString(error.message),
};
const maybe = error as Error & {
status?: number;
body?: string;
details?: unknown;
cause?: unknown;
};
if (typeof maybe.status === "number") out.status = maybe.status;
if (maybe.details !== undefined) out.details = sanitizeUnknown(maybe.details);
if (maybe.cause !== undefined) out.cause = sanitizeUnknown(maybe.cause);
if (maybe.body !== undefined) out.body = REDACTED;
if (error.stack) out.stack = redactString(error.stack, 1200);
return out;
}
export function sanitizeWebhookPayload(
payload: unknown,
): Record<string, unknown> {
const raw = sanitizeUnknown(payload, {
depth: 4,
maxItems: 20,
maxString: 300,
});
return (raw && typeof raw === "object" ? raw : { value: raw }) as Record<
string,
unknown
>;
}

View File

@ -8,6 +8,8 @@
*/ */
import { join } from "node:path"; import { join } from "node:path";
import { logger } from "@infra/logger";
import { runWithRequestContext } from "@infra/request-context";
import type { PipelineConfig } from "@shared/types"; import type { PipelineConfig } from "@shared/types";
import { getDataDir } from "../config/dataDir"; import { getDataDir } from "../config/dataDir";
import * as jobsRepo from "../repositories/jobs"; import * as jobsRepo from "../repositories/jobs";
@ -71,11 +73,13 @@ export async function runPipeline(
const mergedConfig = { ...DEFAULT_CONFIG, ...config }; const mergedConfig = { ...DEFAULT_CONFIG, ...config };
const pipelineRun = await pipelineRepo.createPipelineRun(); const pipelineRun = await pipelineRepo.createPipelineRun();
return runWithRequestContext({ pipelineRunId: pipelineRun.id }, async () => {
console.log("🚀 Starting job pipeline..."); const pipelineLogger = logger.child({ pipelineRunId: pipelineRun.id });
console.log( pipelineLogger.info("Starting pipeline run", {
` Config: topN=${mergedConfig.topN}, minScore=${mergedConfig.minSuitabilityScore} (manual processing)`, topN: mergedConfig.topN,
); minSuitabilityScore: mergedConfig.minSuitabilityScore,
sources: mergedConfig.sources,
});
try { try {
const profile = await loadProfileStep(); const profile = await loadProfileStep();
@ -95,10 +99,9 @@ export async function runPipeline(
mergedConfig, mergedConfig,
}); });
console.log("\n🏭 Auto-processing top jobs..."); pipelineLogger.info("Selected jobs for processing", {
console.log( candidates: jobsToProcess.length,
` Found ${jobsToProcess.length} candidates (score >= ${mergedConfig.minSuitabilityScore}, top ${mergedConfig.topN})`, });
);
const { processedCount } = await processJobsStep({ const { processedCount } = await processJobsStep({
jobsToProcess, jobsToProcess,
@ -111,11 +114,11 @@ export async function runPipeline(
jobsProcessed: processedCount, jobsProcessed: processedCount,
}); });
console.log("\n🎉 Pipeline completed!");
console.log(` Jobs discovered: ${created}`);
console.log(` Jobs processed: ${processedCount}`);
progressHelpers.complete(created, processedCount); progressHelpers.complete(created, processedCount);
pipelineLogger.info("Pipeline run completed", {
jobsDiscovered: created,
jobsProcessed: processedCount,
});
await notifyPipelineWebhookStep("pipeline.completed", { await notifyPipelineWebhookStep("pipeline.completed", {
pipelineRunId: pipelineRun.id, pipelineRunId: pipelineRun.id,
@ -123,7 +126,6 @@ export async function runPipeline(
jobsScored: unprocessedJobs.length, jobsScored: unprocessedJobs.length,
jobsProcessed: processedCount, jobsProcessed: processedCount,
}); });
isPipelineRunning = false;
return { return {
success: true, success: true,
@ -140,14 +142,12 @@ export async function runPipeline(
}); });
progressHelpers.failed(message); progressHelpers.failed(message);
pipelineLogger.error("Pipeline run failed", error);
await notifyPipelineWebhookStep("pipeline.failed", { await notifyPipelineWebhookStep("pipeline.failed", {
pipelineRunId: pipelineRun.id, pipelineRunId: pipelineRun.id,
error: message, error: message,
}); });
isPipelineRunning = false;
console.error("\n❌ Pipeline failed:", message);
return { return {
success: false, success: false,
@ -155,7 +155,10 @@ export async function runPipeline(
jobsProcessed: 0, jobsProcessed: 0,
error: message, error: message,
}; };
} finally {
isPipelineRunning = false;
} }
});
} }
export type ProcessJobOptions = { export type ProcessJobOptions = {
@ -172,8 +175,9 @@ export async function summarizeJob(
success: boolean; success: boolean;
error?: string; error?: string;
}> { }> {
console.log(`📝 Summarizing job ${jobId}...`); return runWithRequestContext({ jobId }, async () => {
const jobLogger = logger.child({ jobId });
jobLogger.info("Summarizing job");
try { try {
const job = await jobsRepo.getJobById(jobId); const job = await jobsRepo.getJobById(jobId);
if (!job) return { success: false, error: "Job not found" }; if (!job) return { success: false, error: "Job not found" };
@ -186,7 +190,7 @@ export async function summarizeJob(
let tailoredSkills = job.tailoredSkills; let tailoredSkills = job.tailoredSkills;
if (!tailoredSummary || !tailoredHeadline || options?.force) { if (!tailoredSummary || !tailoredHeadline || options?.force) {
console.log(" Generating tailoring (summary, headline, skills)..."); jobLogger.info("Generating tailoring content");
const tailoringResult = await generateTailoring( const tailoringResult = await generateTailoring(
job.jobDescription || "", job.jobDescription || "",
profile, profile,
@ -206,9 +210,10 @@ export async function summarizeJob(
// 2. Suggest Projects // 2. Suggest Projects
let selectedProjectIds = job.selectedProjectIds; let selectedProjectIds = job.selectedProjectIds;
if (!selectedProjectIds || options?.force) { if (!selectedProjectIds || options?.force) {
console.log(" Suggesting projects..."); jobLogger.info("Selecting projects");
try { try {
const { catalog, selectionItems } = extractProjectsFromProfile(profile); const { catalog, selectionItems } =
extractProjectsFromProfile(profile);
const overrideResumeProjectsRaw = await getSetting("resumeProjects"); const overrideResumeProjectsRaw = await getSetting("resumeProjects");
const { resumeProjects } = resolveResumeProjectsSettings({ const { resumeProjects } = resolveResumeProjectsSettings({
catalog, catalog,
@ -232,8 +237,8 @@ export async function summarizeJob(
}); });
selectedProjectIds = [...locked, ...picked].join(","); selectedProjectIds = [...locked, ...picked].join(",");
} catch (_err) { } catch (error) {
console.warn(" ⚠️ Failed to suggest projects, leaving empty"); jobLogger.warn("Failed to suggest projects", error);
} }
} }
@ -247,8 +252,10 @@ export async function summarizeJob(
return { success: true }; return { success: true };
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : "Unknown error"; const message = error instanceof Error ? error.message : "Unknown error";
jobLogger.error("Summarization failed", error);
return { success: false, error: message }; return { success: false, error: message };
} }
});
} }
/** /**
@ -261,8 +268,9 @@ export async function generateFinalPdf(
success: boolean; success: boolean;
error?: string; error?: string;
}> { }> {
console.log(`📄 Generating final PDF for job ${jobId}...`); return runWithRequestContext({ jobId }, async () => {
const jobLogger = logger.child({ jobId });
jobLogger.info("Generating final PDF");
try { try {
const job = await jobsRepo.getJobById(jobId); const job = await jobsRepo.getJobById(jobId);
if (!job) return { success: false, error: "Job not found" }; if (!job) return { success: false, error: "Job not found" };
@ -296,8 +304,10 @@ export async function generateFinalPdf(
return { success: true }; return { success: true };
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : "Unknown error"; const message = error instanceof Error ? error.message : "Unknown error";
jobLogger.error("PDF generation failed", error);
return { success: false, error: message }; return { success: false, error: message };
} }
});
} }
/** /**

View File

@ -1,3 +1,5 @@
import { logger } from "@infra/logger";
/** /**
* Pipeline progress tracking with Server-Sent Events. * Pipeline progress tracking with Server-Sent Events.
*/ */
@ -67,7 +69,7 @@ export function updateProgress(update: Partial<PipelineProgress>): void {
try { try {
listener(currentProgress); listener(currentProgress);
} catch (error) { } catch (error) {
console.error("Error in progress listener:", error); logger.error("Error in progress listener", error);
} }
} }
} }

View File

@ -1,3 +1,4 @@
import { logger } from "@infra/logger";
import type { CreateJobInput, PipelineConfig } from "@shared/types"; import type { CreateJobInput, PipelineConfig } from "@shared/types";
import * as jobsRepo from "../../repositories/jobs"; import * as jobsRepo from "../../repositories/jobs";
import * as settingsRepo from "../../repositories/settings"; import * as settingsRepo from "../../repositories/settings";
@ -12,7 +13,7 @@ export async function discoverJobsStep(args: {
discoveredJobs: CreateJobInput[]; discoveredJobs: CreateJobInput[];
sourceErrors: string[]; sourceErrors: string[];
}> { }> {
console.log("\n🕷 Running crawler..."); logger.info("Running discovery step");
progressHelpers.startCrawling(); progressHelpers.startCrawling();
const discoveredJobs: CreateJobInput[] = []; const discoveredJobs: CreateJobInput[] = [];
@ -149,7 +150,7 @@ export async function discoverJobsStep(args: {
} }
if (sourceErrors.length > 0) { if (sourceErrors.length > 0) {
console.warn(`⚠️ Some sources failed: ${sourceErrors.join("; ")}`); logger.warn("Some discovery sources failed", { sourceErrors });
} }
progressHelpers.crawlingComplete(discoveredJobs.length); progressHelpers.crawlingComplete(discoveredJobs.length);

View File

@ -1,3 +1,4 @@
import { logger } from "@infra/logger";
import type { CreateJobInput } from "@shared/types"; import type { CreateJobInput } from "@shared/types";
import * as jobsRepo from "../../repositories/jobs"; import * as jobsRepo from "../../repositories/jobs";
import { progressHelpers } from "../progress"; import { progressHelpers } from "../progress";
@ -5,11 +6,11 @@ import { progressHelpers } from "../progress";
export async function importJobsStep(args: { export async function importJobsStep(args: {
discoveredJobs: CreateJobInput[]; discoveredJobs: CreateJobInput[];
}): Promise<{ created: number; skipped: number }> { }): Promise<{ created: number; skipped: number }> {
console.log("\n💾 Importing jobs to database..."); logger.info("Importing discovered jobs");
const { created, skipped } = await jobsRepo.bulkCreateJobs( const { created, skipped } = await jobsRepo.bulkCreateJobs(
args.discoveredJobs, args.discoveredJobs,
); );
console.log(` Created: ${created}, Skipped (duplicates): ${skipped}`); logger.info("Import step complete", { created, skipped });
progressHelpers.importComplete(created, skipped); progressHelpers.importComplete(created, skipped);

View File

@ -1,10 +1,11 @@
import { logger } from "@infra/logger";
import { getProfile } from "../../services/profile"; import { getProfile } from "../../services/profile";
export async function loadProfileStep(): Promise<Record<string, unknown>> { export async function loadProfileStep(): Promise<Record<string, unknown>> {
console.log("\n📋 Loading profile..."); logger.info("Loading profile");
return getProfile().catch((error) => { return getProfile().catch((error) => {
console.warn( logger.warn(
"⚠️ Failed to load profile for scoring, using empty profile:", "Failed to load profile for scoring, using empty profile",
error, error,
); );
return {} as Record<string, unknown>; return {} as Record<string, unknown>;

View File

@ -1,3 +1,5 @@
import { logger } from "@infra/logger";
import { sanitizeWebhookPayload } from "@infra/sanitize";
import * as settingsRepo from "../../repositories/settings"; import * as settingsRepo from "../../repositories/settings";
export async function notifyPipelineWebhookStep( export async function notifyPipelineWebhookStep(
@ -22,22 +24,30 @@ export async function notifyPipelineWebhookStep(
const secret = process.env.WEBHOOK_SECRET; const secret = process.env.WEBHOOK_SECRET;
if (secret) headers.Authorization = `Bearer ${secret}`; if (secret) headers.Authorization = `Bearer ${secret}`;
const sanitizedPayload = sanitizeWebhookPayload({
event,
sentAt: new Date().toISOString(),
pipelineRunId: payload.pipelineRunId,
jobsDiscovered: payload.jobsDiscovered,
jobsScored: payload.jobsScored,
jobsProcessed: payload.jobsProcessed,
error: payload.error,
});
const response = await fetch(pipelineWebhookUrl, { const response = await fetch(pipelineWebhookUrl, {
method: "POST", method: "POST",
headers, headers,
body: JSON.stringify({ body: JSON.stringify(sanitizedPayload),
event,
sentAt: new Date().toISOString(),
...payload,
}),
}); });
if (!response.ok) { if (!response.ok) {
console.warn( const responseText = await response.text().catch(() => "");
`⚠️ Pipeline webhook POST failed (${response.status}): ${await response.text()}`, logger.warn("Pipeline webhook POST failed", {
); status: response.status,
error: responseText.slice(0, 200),
});
} }
} catch (error) { } catch (error) {
console.warn("⚠️ Pipeline webhook POST failed:", error); logger.warn("Pipeline webhook POST failed", error);
} }
} }

View File

@ -1,3 +1,4 @@
import { logger } from "@infra/logger";
import { progressHelpers, updateProgress } from "../progress"; import { progressHelpers, updateProgress } from "../progress";
import type { ScoredJob } from "./types"; import type { ScoredJob } from "./types";
@ -28,7 +29,10 @@ export async function processJobsStep(args: {
if (result.success) { if (result.success) {
processedCount++; processedCount++;
} else { } else {
console.warn(` ⚠️ Failed to process job ${job.id}: ${result.error}`); logger.warn("Failed to process job", {
jobId: job.id,
error: result.error,
});
} }
progressHelpers.jobComplete(i + 1, args.jobsToProcess.length); progressHelpers.jobComplete(i + 1, args.jobsToProcess.length);

View File

@ -1,3 +1,4 @@
import { logger } from "@infra/logger";
import type { Job } from "@shared/types"; import type { Job } from "@shared/types";
import * as jobsRepo from "../../repositories/jobs"; import * as jobsRepo from "../../repositories/jobs";
import { scoreJobSuitability } from "../../services/scorer"; import { scoreJobSuitability } from "../../services/scorer";
@ -8,7 +9,7 @@ import type { ScoredJob } from "./types";
export async function scoreJobsStep(args: { export async function scoreJobsStep(args: {
profile: Record<string, unknown>; profile: Record<string, unknown>;
}): Promise<{ unprocessedJobs: Job[]; scoredJobs: ScoredJob[] }> { }): Promise<{ unprocessedJobs: Job[]; scoredJobs: ScoredJob[] }> {
console.log("\n🎯 Scoring jobs for suitability..."); logger.info("Running scoring step");
const unprocessedJobs = await jobsRepo.getUnscoredDiscoveredJobs(); const unprocessedJobs = await jobsRepo.getUnscoredDiscoveredJobs();
updateProgress({ updateProgress({
@ -73,7 +74,7 @@ export async function scoreJobsStep(args: {
} }
progressHelpers.scoringComplete(scoredJobs.length); progressHelpers.scoringComplete(scoredJobs.length);
console.log(`\n📊 Scored ${scoredJobs.length} jobs.`); logger.info("Scoring step completed", { scoredJobs: scoredJobs.length });
return { unprocessedJobs, scoredJobs }; return { unprocessedJobs, scoredJobs };
} }

View File

@ -1,3 +1,4 @@
import { logger } from "@infra/logger";
import { toStringOrNull } from "@shared/utils/type-conversion"; import { toStringOrNull } from "@shared/utils/type-conversion";
import { import {
buildModeCacheKey, buildModeCacheKey,
@ -51,7 +52,7 @@ export class LlmService {
resolvedProvider === "openrouter" && resolvedProvider === "openrouter" &&
toStringOrNull(process.env.OPENROUTER_API_KEY) toStringOrNull(process.env.OPENROUTER_API_KEY)
) { ) {
console.warn( logger.warn(
"[DEPRECATED] OPENROUTER_API_KEY is deprecated. Copying to LLM_API_KEY; please update your environment.", "[DEPRECATED] OPENROUTER_API_KEY is deprecated. Copying to LLM_API_KEY; please update your environment.",
); );
const migrated = toStringOrNull(process.env.OPENROUTER_API_KEY); const migrated = toStringOrNull(process.env.OPENROUTER_API_KEY);
@ -180,9 +181,11 @@ export class LlmService {
for (let attempt = 0; attempt <= maxRetries; attempt++) { for (let attempt = 0; attempt <= maxRetries; attempt++) {
try { try {
if (attempt > 0) { if (attempt > 0) {
console.log( logger.info("LLM retry attempt", {
`🔄 [${jobId ?? "unknown"}] Retry attempt ${attempt}/${maxRetries}...`, jobId: jobId ?? "unknown",
); attempt,
maxRetries,
});
await sleep(getRetryDelayMs(retryDelayMs, attempt)); await sleep(getRetryDelayMs(retryDelayMs, attempt));
} }
@ -209,7 +212,7 @@ export class LlmService {
`LLM API error: ${response.status}${detail}`, `LLM API error: ${response.status}${detail}`,
) as LlmApiError; ) as LlmApiError;
err.status = response.status; err.status = response.status;
err.body = errorBody; err.body = truncate(errorBody, 600);
throw err; throw err;
} }
@ -238,9 +241,13 @@ export class LlmService {
} }
if (attempt < maxRetries && shouldRetryAttempt({ message, status })) { if (attempt < maxRetries && shouldRetryAttempt({ message, status })) {
console.warn( logger.warn("LLM attempt failed, retrying", {
`⚠️ [${jobId ?? "unknown"}] Attempt ${attempt + 1} failed (${status ?? "no-status"}): ${message}. Retrying...`, jobId: jobId ?? "unknown",
); attempt: attempt + 1,
maxRetries,
status: status ?? "no-status",
message,
});
continue; continue;
} }
@ -271,9 +278,9 @@ function normalizeProvider(
if (normalized === "lmstudio") return "lmstudio"; if (normalized === "lmstudio") return "lmstudio";
if (normalized === "ollama") return "ollama"; if (normalized === "ollama") return "ollama";
if (normalized && normalized !== "openrouter") { if (normalized && normalized !== "openrouter") {
console.warn( logger.warn("Unknown LLM provider, defaulting to openrouter", {
`⚠️ Unknown LLM provider "${normalized}", defaulting to openrouter`, normalized,
); });
} }
return "openrouter"; return "openrouter";
} }

View File

@ -1,3 +1,5 @@
import { logger } from "@infra/logger";
export function parseJsonContent<T>(content: string, jobId?: string): T { export function parseJsonContent<T>(content: string, jobId?: string): T {
let candidate = content.trim(); let candidate = content.trim();
@ -16,10 +18,10 @@ export function parseJsonContent<T>(content: string, jobId?: string): T {
try { try {
return JSON.parse(candidate) as T; return JSON.parse(candidate) as T;
} catch (error) { } catch (error) {
console.error( logger.error("Failed to parse LLM JSON content", {
`❌ [${jobId ?? "unknown"}] Failed to parse JSON:`, jobId: jobId ?? "unknown",
candidate.substring(0, 200), sample: candidate.substring(0, 200),
); });
throw new Error( throw new Error(
`Failed to parse JSON response: ${error instanceof Error ? error.message : "unknown"}`, `Failed to parse JSON response: ${error instanceof Error ? error.message : "unknown"}`,
); );

View File

@ -2,6 +2,7 @@
* Service for inferring job details from a pasted job description. * Service for inferring job details from a pasted job description.
*/ */
import { logger } from "@infra/logger";
import type { ManualJobDraft } from "@shared/types"; import type { ManualJobDraft } from "@shared/types";
import { getSetting } from "../repositories/settings"; import { getSetting } from "../repositories/settings";
import { type JsonSchemaDefinition, LlmService } from "./llm-service"; import { type JsonSchemaDefinition, LlmService } from "./llm-service";
@ -111,7 +112,7 @@ export async function inferManualJobDetails(
warning: "LLM API key not set. Fill details manually.", warning: "LLM API key not set. Fill details manually.",
}; };
} }
console.warn("Manual job inference failed:", result.error); logger.warn("Manual job inference failed", { error: result.error });
return { return {
job: {}, job: {},
warning: "AI inference failed. Fill details manually.", warning: "AI inference failed. Fill details manually.",

View File

@ -7,6 +7,7 @@
import type { ResumeData } from "@shared/rxresume-schema"; import type { ResumeData } from "@shared/rxresume-schema";
type AnyObj = Record<string, unknown>; type AnyObj = Record<string, unknown>;
const MAX_ERROR_SNIPPET = 300;
const TOKEN_COOKIE_NAMES = [ const TOKEN_COOKIE_NAMES = [
"accessToken", "accessToken",
@ -241,8 +242,10 @@ export class RxResumeClient {
}); });
if (!res.ok) { if (!res.ok) {
const text = await res.text(); const text = await res.text().catch(() => "");
throw new Error(`Login failed: HTTP ${res.status} ${text}`); throw new Error(
`Login failed: HTTP ${res.status} ${sanitizeResponseSnippet(text)}`,
);
} }
const data = (await res.json()) as AnyObj; const data = (await res.json()) as AnyObj;
@ -266,7 +269,7 @@ export class RxResumeClient {
if (!token || typeof token !== "string") { if (!token || typeof token !== "string") {
throw new Error( throw new Error(
`Login succeeded but could not locate access token in response. Response keys: ${Object.keys(data).join(", ")}`, "Login succeeded but could not locate access token in response.",
); );
} }
@ -295,8 +298,10 @@ export class RxResumeClient {
}); });
if (!res.ok) { if (!res.ok) {
const text = await res.text(); const text = await res.text().catch(() => "");
throw new Error(`Create failed: HTTP ${res.status} ${text}`); throw new Error(
`Create failed: HTTP ${res.status} ${sanitizeResponseSnippet(text)}`,
);
} }
const d = (await res.json()) as AnyObj; const d = (await res.json()) as AnyObj;
@ -310,7 +315,7 @@ export class RxResumeClient {
if (!id || typeof id !== "string") { if (!id || typeof id !== "string") {
throw new Error( throw new Error(
`Create succeeded but could not locate resume id in response. Response keys: ${Object.keys(d).join(", ")}`, "Create succeeded but could not locate resume id in response.",
); );
} }
@ -334,8 +339,10 @@ export class RxResumeClient {
); );
if (!res.ok) { if (!res.ok) {
const text = await res.text(); const text = await res.text().catch(() => "");
throw new Error(`Print failed: HTTP ${res.status} ${text}`); throw new Error(
`Print failed: HTTP ${res.status} ${sanitizeResponseSnippet(text)}`,
);
} }
const d = (await res.json()) as AnyObj; const d = (await res.json()) as AnyObj;
@ -348,9 +355,7 @@ export class RxResumeClient {
(d?.result as AnyObj)?.href; (d?.result as AnyObj)?.href;
if (!url || typeof url !== "string") { if (!url || typeof url !== "string") {
throw new Error( throw new Error("Print succeeded but could not locate URL in response.");
`Print succeeded but could not locate URL in response. Response: ${JSON.stringify(d)}`,
);
} }
return url; return url;
@ -372,8 +377,10 @@ export class RxResumeClient {
); );
if (!res.ok && res.status !== 204) { if (!res.ok && res.status !== 204) {
const text = await res.text(); const text = await res.text().catch(() => "");
throw new Error(`Delete failed: HTTP ${res.status} ${text}`); throw new Error(
`Delete failed: HTTP ${res.status} ${sanitizeResponseSnippet(text)}`,
);
} }
} }
@ -416,8 +423,10 @@ export class RxResumeClient {
}); });
if (!res.ok) { if (!res.ok) {
const text = await res.text(); const text = await res.text().catch(() => "");
throw new Error(`List resumes failed: HTTP ${res.status} ${text}`); throw new Error(
`List resumes failed: HTTP ${res.status} ${sanitizeResponseSnippet(text)}`,
);
} }
const data = (await res.json()) as AnyObj | AnyObj[]; const data = (await res.json()) as AnyObj | AnyObj[];
@ -445,3 +454,9 @@ export class RxResumeClient {
return resume; return resume;
} }
} }
function sanitizeResponseSnippet(text: string): string {
if (!text) return "";
const compact = text.replace(/\s+/g, " ").trim();
return compact.slice(0, MAX_ERROR_SNIPPET);
}

View File

@ -2,6 +2,7 @@
* Service for scoring job suitability using AI. * Service for scoring job suitability using AI.
*/ */
import { logger } from "@infra/logger";
import type { Job } from "@shared/types"; import type { Job } from "@shared/types";
import { getSetting } from "../repositories/settings"; import { getSetting } from "../repositories/settings";
import { type JsonSchemaDefinition, LlmService } from "./llm-service"; import { type JsonSchemaDefinition, LlmService } from "./llm-service";
@ -50,7 +51,7 @@ export async function scoreJobSuitability(
process.env.MODEL || process.env.MODEL ||
"google/gemini-3-flash-preview"; "google/gemini-3-flash-preview";
const prompt = buildScoringPrompt(job, profile); const prompt = buildScoringPrompt(job, sanitizeProfileForPrompt(profile));
const llm = new LlmService(); const llm = new LlmService();
const result = await llm.callJson<{ score: number; reason: string }>({ const result = await llm.callJson<{ score: number; reason: string }>({
@ -63,11 +64,12 @@ export async function scoreJobSuitability(
if (!result.success) { if (!result.success) {
if (result.error.toLowerCase().includes("api key")) { if (result.error.toLowerCase().includes("api key")) {
console.warn("⚠️ LLM API key not set, using mock scoring"); logger.warn("LLM API key not set, using mock scoring", { jobId: job.id });
} }
console.error( logger.error("Scoring failed, using mock scoring", {
`❌ [Job ${job.id}] Scoring failed: ${result.error}, using mock scoring`, jobId: job.id,
); error: result.error,
});
return mockScore(job); return mockScore(job);
} }
@ -75,9 +77,9 @@ export async function scoreJobSuitability(
// Validate we got a reasonable response // Validate we got a reasonable response
if (typeof score !== "number" || Number.isNaN(score)) { if (typeof score !== "number" || Number.isNaN(score)) {
console.error( logger.error("Invalid score in AI response, using mock scoring", {
`❌ [Job ${job.id}] Invalid score in response, using mock scoring`, jobId: job.id,
); });
return mockScore(job); return mockScore(job);
} }
@ -173,21 +175,19 @@ export function parseJsonFromContent(
const reason = reasonMatch const reason = reasonMatch
? reasonMatch[1].trim().replace(controlCharsRegex, "") ? reasonMatch[1].trim().replace(controlCharsRegex, "")
: "Score extracted from malformed response"; : "Score extracted from malformed response";
console.log( logger.warn("Parsed score via regex fallback", {
`⚠️ [Job ${jobId || "unknown"}] Parsed score via regex fallback: ${score}`, jobId: jobId || "unknown",
); score,
});
return { score, reason }; return { score, reason };
} }
// Log the failure with full content for debugging // Log the failure with full content for debugging
console.error( logger.error("Failed to parse AI response", {
`❌ [Job ${jobId || "unknown"}] Failed to parse AI response. Raw content (first 500 chars):`, jobId: jobId || "unknown",
originalContent.substring(0, 500), rawSample: originalContent.substring(0, 500),
); sanitizedSample: sanitized.substring(0, 500),
console.error( });
` Sanitized content (first 500 chars):`,
sanitized.substring(0, 500),
);
throw new Error("Unable to parse JSON from model response"); throw new Error("Unable to parse JSON from model response");
} }
@ -228,6 +228,38 @@ EXAMPLE VALID RESPONSE:
{"score": 75, "reason": "Strong skills match with React and TypeScript requirements, but position requires 3+ years experience."}`; {"score": 75, "reason": "Strong skills match with React and TypeScript requirements, but position requires 3+ years experience."}`;
} }
function sanitizeProfileForPrompt(
profile: Record<string, unknown>,
): Record<string, unknown> {
const p = profile as {
basics?: Record<string, unknown>;
sections?: {
skills?: unknown;
experience?: { items?: unknown[] };
projects?: { items?: unknown[] };
education?: { items?: unknown[] };
};
};
const experienceItems = Array.isArray(p.sections?.experience?.items)
? p.sections?.experience?.items.slice(0, 5)
: [];
const projectItems = Array.isArray(p.sections?.projects?.items)
? p.sections?.projects?.items.slice(0, 6)
: [];
return {
basics: {
label: p.basics?.label,
summary: p.basics?.summary,
},
skills: p.sections?.skills ?? null,
experience: experienceItems,
projects: projectItems,
education: p.sections?.education?.items ?? [],
};
}
function mockScore(job: Job): SuitabilityResult { function mockScore(job: Job): SuitabilityResult {
// Simple keyword-based scoring as fallback // Simple keyword-based scoring as fallback
const jd = (job.jobDescription || "").toLowerCase(); const jd = (job.jobDescription || "").toLowerCase();

View File

@ -2,6 +2,7 @@
* Service for generating tailored resume content (Summary, Headline, Skills). * Service for generating tailored resume content (Summary, Headline, Skills).
*/ */
import { logger } from "@infra/logger";
import type { ResumeProfile } from "@shared/types"; import type { ResumeProfile } from "@shared/types";
import { getSetting } from "../repositories/settings"; import { getSetting } from "../repositories/settings";
import { type JsonSchemaDefinition, LlmService } from "./llm-service"; import { type JsonSchemaDefinition, LlmService } from "./llm-service";
@ -88,7 +89,7 @@ export async function generateTailoring(
const context = `provider=${llm.getProvider()} baseUrl=${llm.getBaseUrl()}`; const context = `provider=${llm.getProvider()} baseUrl=${llm.getBaseUrl()}`;
if (result.error.toLowerCase().includes("api key")) { if (result.error.toLowerCase().includes("api key")) {
const message = `LLM API key not set, cannot generate tailoring. (${context})`; const message = `LLM API key not set, cannot generate tailoring. (${context})`;
console.warn(`⚠️ ${message}`); logger.warn(message);
return { success: false, error: message }; return { success: false, error: message };
} }
return { return {
@ -101,7 +102,7 @@ export async function generateTailoring(
// Basic validation // Basic validation
if (!summary || !headline || !Array.isArray(skills)) { if (!summary || !headline || !Array.isArray(skills)) {
console.warn("⚠️ AI response missing required fields:", result.data); logger.warn("AI response missing required tailoring fields", result.data);
} }
return { return {

View File

@ -14,6 +14,7 @@
"paths": { "paths": {
"@/*": ["src/*"], "@/*": ["src/*"],
"@server/*": ["src/server/*"], "@server/*": ["src/server/*"],
"@infra/*": ["src/server/infra/*"],
"@client/*": ["src/client/*"], "@client/*": ["src/client/*"],
"@shared/*": ["../shared/src/*"] "@shared/*": ["../shared/src/*"]
} }

View File

@ -23,6 +23,7 @@ export default defineConfig({
"@": path.resolve(__dirname, "./src"), "@": path.resolve(__dirname, "./src"),
"@client": path.resolve(__dirname, "./src/client"), "@client": path.resolve(__dirname, "./src/client"),
"@server": path.resolve(__dirname, "./src/server"), "@server": path.resolve(__dirname, "./src/server"),
"@infra": path.resolve(__dirname, "./src/server/infra"),
"@shared": path.resolve(__dirname, "../shared/src"), "@shared": path.resolve(__dirname, "../shared/src"),
}, },
}, },

View File

@ -10,8 +10,8 @@
"check:types:shared": "npm --workspace shared run check:types", "check:types:shared": "npm --workspace shared run check:types",
"check:types": "npm --workspace shared run check:types && npm --workspace orchestrator run check:types", "check:types": "npm --workspace shared run check:types && npm --workspace orchestrator run check:types",
"check:types:ukvisajobs": "npm --workspace ukvisajobs-extractor run check:types", "check:types:ukvisajobs": "npm --workspace ukvisajobs-extractor run check:types",
"check:all": "npx biome ci .", "check:all": "./orchestrator/node_modules/.bin/biome ci .",
"format:all": "npx biome format . --write", "format:all": "./orchestrator/node_modules/.bin/biome format . --write",
"check:types:gradcracker": "npm --workspace gradcracker-extractor run check:types" "check:types:gradcracker": "npm --workspace gradcracker-extractor run check:types"
}, },
"devDependencies": { "devDependencies": {

View File

@ -309,12 +309,28 @@ export interface PipelineRun {
} }
// API Response types // API Response types
export interface ApiResponse<T> { export interface ApiMeta {
success: boolean; requestId: string;
data?: T;
error?: string;
} }
export interface ApiErrorPayload {
code: string;
message: string;
details?: unknown;
}
export type ApiResponse<T> =
| {
ok: true;
data: T;
meta?: ApiMeta;
}
| {
ok: false;
error: ApiErrorPayload;
meta: ApiMeta;
};
export interface JobsListResponse { export interface JobsListResponse {
jobs: Job[]; jobs: Job[];
total: number; total: number;