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`.
- 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`.
## 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";
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>(
endpoint: string,
options?: RequestInit,
@ -49,22 +88,38 @@ async function fetchApi<T>(
const text = await response.text();
let data: ApiResponse<T>;
let payload: unknown;
try {
data = JSON.parse(text);
payload = JSON.parse(text);
} catch {
// If the response is not JSON, it's likely an HTML error page
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?`,
);
}
const parsed = normalizeApiResponse<T>(payload);
if (!data.success) {
throw new Error(data.error || "API request failed");
if ("ok" in parsed) {
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

View File

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

View File

@ -1,3 +1,4 @@
import { logger } from "@infra/logger";
import {
createBackup,
deleteBackup,
@ -25,7 +26,7 @@ backupRouter.get("/", async (_req: Request, res: Response) => {
});
} catch (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 });
}
});
@ -49,7 +50,7 @@ backupRouter.post("/", async (_req: Request, res: Response) => {
});
} catch (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 });
}
});
@ -77,10 +78,10 @@ backupRouter.delete("/:filename", async (req: Request, res: Response) => {
});
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
console.error(
`❌ [backup-api] Failed to delete backup ${req.params.filename}:`,
logger.error("Failed to delete backup", {
filename: req.params.filename,
error,
);
});
if (message.includes("not found")) {
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 body = await res.json();
expect(body.success).toBe(true);
expect(body.ok).toBe(true);
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 listBody = await listRes.json();
expect(listBody.success).toBe(true);
expect(listBody.ok).toBe(true);
expect(listBody.data.total).toBe(1);
expect(listBody.data.jobs[0].id).toBe(job.id);
@ -92,7 +92,7 @@ describe.sequential("Jobs API routes", () => {
method: "POST",
});
const body = await res.json();
expect(body.success).toBe(true);
expect(body.ok).toBe(true);
expect(body.data.status).toBe("applied");
expect(body.data.notionPageId).toBe("page-123");
expect(body.data.appliedAt).toBeTruthy();
@ -135,7 +135,7 @@ describe.sequential("Jobs API routes", () => {
});
const body = await res.json();
expect(body.success).toBe(true);
expect(body.ok).toBe(true);
expect(body.data.suitabilityScore).toBe(77);
expect(body.data.suitabilityReason).toBe("Updated fit");
});
@ -165,7 +165,7 @@ describe.sequential("Jobs API routes", () => {
});
const body = await res.json();
expect(body.success).toBe(true);
expect(body.ok).toBe(true);
expect(body.data.sponsorMatchScore).toBe(100);
expect(body.data.sponsorMatchNames).toContain("ACME CORP SPONSOR");
});
@ -192,7 +192,7 @@ describe.sequential("Jobs API routes", () => {
body: JSON.stringify({ toStage: "applied" }),
});
const body1 = await trans1.json();
expect(body1.success).toBe(true);
expect(body1.ok).toBe(true);
expect(body1.data.toStage).toBe("applied");
const eventId = body1.data.id;
@ -209,7 +209,7 @@ describe.sequential("Jobs API routes", () => {
// 3. Get events
const eventsRes = await fetch(`${baseUrl}/api/jobs/${jobId}/events`);
const eventsBody = await eventsRes.json();
expect(eventsBody.success).toBe(true);
expect(eventsBody.ok).toBe(true);
expect(eventsBody.data).toHaveLength(2);
expect(eventsBody.data[0].toStage).toBe("applied");
expect(eventsBody.data[1].toStage).toBe("recruiter_screen");
@ -252,7 +252,7 @@ describe.sequential("Jobs API routes", () => {
// 1. Initial state
const res1 = await fetch(`${baseUrl}/api/jobs/${jobId}/tasks`);
const body1 = await res1.json();
expect(body1.success).toBe(true);
expect(body1.ok).toBe(true);
expect(body1.data).toEqual([]);
// 2. Insert a task
@ -297,7 +297,7 @@ describe.sequential("Jobs API routes", () => {
body: JSON.stringify({ outcome: "rejected" }),
});
const body = await res.json();
expect(body.success).toBe(true);
expect(body.ok).toBe(true);
expect(body.data.outcome).toBe("rejected");
expect(body.data.closedAt).toBeTruthy();
});

View File

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

View File

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

View File

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

View File

@ -30,7 +30,7 @@ describe.sequential("Onboarding API routes", () => {
const body = await res.json();
expect(res.ok).toBe(true);
expect(body.success).toBe(true);
expect(body.ok).toBe(true);
expect(body.data.valid).toBe(false);
expect(body.data.message).toContain("missing");
});
@ -68,7 +68,7 @@ describe.sequential("Onboarding API routes", () => {
const body = await res.json();
expect(res.ok).toBe(true);
expect(body.success).toBe(true);
expect(body.ok).toBe(true);
// Should be invalid because the key is fake
expect(body.data.valid).toBe(false);
});
@ -84,7 +84,7 @@ describe.sequential("Onboarding API routes", () => {
const body = await res.json();
expect(res.ok).toBe(true);
expect(body.success).toBe(true);
expect(body.ok).toBe(true);
expect(body.data.valid).toBe(false);
expect(body.data.message).toContain("missing");
});
@ -132,7 +132,7 @@ describe.sequential("Onboarding API routes", () => {
const body = await res.json();
expect(res.ok).toBe(true);
expect(body.success).toBe(true);
expect(body.ok).toBe(true);
// Should be invalid because credentials are fake
expect(body.data.valid).toBe(false);
});
@ -157,7 +157,7 @@ describe.sequential("Onboarding API routes", () => {
const body = await res.json();
expect(res.ok).toBe(true);
expect(body.success).toBe(true);
expect(body.ok).toBe(true);
expect(body.data.valid).toBe(false);
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 () => {
const res = await fetch(`${baseUrl}/api/pipeline/status`);
const body = await res.json();
expect(body.success).toBe(true);
expect(body.ok).toBe(true);
expect(body.data.isRunning).toBe(false);
expect(body.data.lastRun).toBeNull();
});
@ -39,7 +39,7 @@ describe.sequential("Pipeline API routes", () => {
body: JSON.stringify({ topN: 5, sources: ["gradcracker"] }),
});
const runBody = await runRes.json();
expect(runBody.success).toBe(true);
expect(runBody.ok).toBe(true);
expect(runPipeline).toHaveBeenCalledWith({
topN: 5,
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 Request, type Response, Router } from "express";
import { z } from "zod";
@ -19,7 +21,7 @@ pipelineRouter.get("/status", async (_req: Request, res: Response) => {
const lastRun = await pipelineRepo.getLatestPipelineRun();
const response: ApiResponse<PipelineStatusResponse> = {
success: true,
ok: true,
data: {
isRunning,
lastRun,
@ -30,7 +32,9 @@ pipelineRouter.get("/status", async (_req: Request, res: Response) => {
res.json(response);
} catch (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) => {
try {
const runs = await pipelineRepo.getRecentPipelineRuns(20);
res.json({ success: true, data: runs });
res.json({ ok: true, data: runs });
} catch (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);
// Start pipeline in background
runPipeline(config).catch(console.error);
runWithRequestContext({}, () => {
runPipeline(config).catch((error) => {
logger.error("Background pipeline run failed", error);
});
});
res.json({
success: true,
ok: true,
data: { message: "Pipeline started" },
});
} catch (error) {
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";
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();
expect(res.ok).toBe(true);
expect(body.success).toBe(true);
expect(body.ok).toBe(true);
expect(Array.isArray(body.data)).toBe(true);
expect(body.data.length).toBe(2);
});
@ -97,8 +97,8 @@ describe.sequential("Profile API routes", () => {
const body = await res.json();
expect(res.ok).toBe(false);
expect(body.success).toBe(false);
expect(body.error).toContain("Base resume not configured");
expect(body.ok).toBe(false);
expect(body.error.message).toContain("Base resume not configured");
});
});
@ -114,7 +114,7 @@ describe.sequential("Profile API routes", () => {
const body = await res.json();
expect(res.ok).toBe(true);
expect(body.success).toBe(true);
expect(body.ok).toBe(true);
expect(body.data).toEqual(mockProfile);
});
@ -127,8 +127,8 @@ describe.sequential("Profile API routes", () => {
const body = await res.json();
expect(res.ok).toBe(false);
expect(body.success).toBe(false);
expect(body.error).toContain("Base resume not configured");
expect(body.ok).toBe(false);
expect(body.error.message).toContain("Base resume not configured");
});
});
@ -140,7 +140,7 @@ describe.sequential("Profile API routes", () => {
const body = await res.json();
expect(res.ok).toBe(true);
expect(body.success).toBe(true);
expect(body.ok).toBe(true);
expect(body.data.exists).toBe(false);
expect(body.data.error).toContain("No base resume selected");
});
@ -156,7 +156,7 @@ describe.sequential("Profile API routes", () => {
const body = await res.json();
expect(res.ok).toBe(true);
expect(body.success).toBe(true);
expect(body.ok).toBe(true);
expect(body.data.exists).toBe(true);
expect(body.data.error).toBeNull();
});
@ -169,7 +169,7 @@ describe.sequential("Profile API routes", () => {
const body = await res.json();
expect(res.ok).toBe(true);
expect(body.success).toBe(true);
expect(body.ok).toBe(true);
expect(body.data.exists).toBe(false);
expect(body.data.error).toContain("credentials not configured");
});
@ -185,7 +185,7 @@ describe.sequential("Profile API routes", () => {
const body = await res.json();
expect(res.ok).toBe(true);
expect(body.success).toBe(true);
expect(body.ok).toBe(true);
expect(body.data.exists).toBe(false);
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 () => {
const res = await fetch(`${baseUrl}/api/settings`);
const body = await res.json();
expect(body.success).toBe(true);
expect(body.ok).toBe(true);
expect(body.data.defaultModel).toBe("test-model");
expect(Array.isArray(body.data.searchTerms)).toBe(true);
expect(body.data.rxresumeEmail).toBe("resume@example.com");
@ -51,7 +51,7 @@ describe.sequential("Settings API routes", () => {
}),
});
const patchBody = await patchRes.json();
expect(patchBody.success).toBe(true);
expect(patchBody.ok).toBe(true);
expect(patchBody.data.searchTerms).toEqual(["engineer"]);
expect(patchBody.data.overrideSearchTerms).toEqual(["engineer"]);
expect(patchBody.data.rxresumeEmail).toBe("updated@example.com");
@ -70,7 +70,7 @@ describe.sequential("Settings API routes", () => {
});
expect(res.status).toBe(400);
const body = await res.json();
expect(body.success).toBe(false);
expect(body.error).toContain("Username is required");
expect(body.ok).toBe(false);
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 { extractProjectsFromProfile } from "@server/services/resumeProjects";
import {
@ -72,7 +73,7 @@ settingsRouter.get("/rx-resumes", async (_req: Request, res: Response) => {
return;
}
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 });
}
});
@ -103,7 +104,7 @@ settingsRouter.get(
return;
}
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 });
}
},

View File

@ -47,7 +47,7 @@ describe.sequential("UK Visa Jobs API routes", () => {
body: JSON.stringify({ query: "engineer" }),
});
const body = await res.json();
expect(body.success).toBe(true);
expect(body.ok).toBe(true);
expect(body.data.totalPages).toBe(2);
expect(fetchUkVisaJobsPage).toHaveBeenCalledWith({
searchKeyword: "engineer",
@ -87,7 +87,7 @@ describe.sequential("UK Visa Jobs API routes", () => {
}),
});
const body = await res.json();
expect(body.success).toBe(true);
expect(body.ok).toBe(true);
expect(body.data.created).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> = {
success: true,
ok: true,
data: {
jobs: result.jobs,
totalJobs: result.totalJobs,
@ -133,7 +133,7 @@ ukVisaJobsRouter.post("/import", async (req: Request, res: Response) => {
const result = await jobsRepo.bulkCreateJobs(jobs);
const response: ApiResponse<UkVisaJobsImportResponse> = {
success: true,
ok: true,
data: {
created: result.created,
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 statusBody = await statusRes.json();
expect(statusBody.success).toBe(true);
expect(statusBody.ok).toBe(true);
expect(statusBody.data.totalSponsors).toBe(0);
const updateRes = await fetch(`${baseUrl}/api/visa-sponsors/update`, {
@ -76,7 +76,7 @@ describe.sequential("Visa sponsors API routes", () => {
body: JSON.stringify({ query: "Acme" }),
});
const body = await res.json();
expect(body.success).toBe(true);
expect(body.ok).toBe(true);
expect(body.data.total).toBe(1);
const orgRes = await fetch(

View File

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

View File

@ -29,7 +29,7 @@ describe.sequential("Webhook API routes", () => {
headers: { Authorization: "Bearer secret" },
});
const goodBody = await goodRes.json();
expect(goodBody.success).toBe(true);
expect(goodBody.ok).toBe(true);
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 { runPipeline } from "../../pipeline/index";
@ -12,15 +14,22 @@ webhookRouter.post("/trigger", async (req: Request, res: Response) => {
const expectedToken = process.env.WEBHOOK_SECRET;
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 {
// Start pipeline in background
runPipeline().catch(console.error);
runWithRequestContext({}, () => {
runPipeline().catch((error) => {
logger.error("Webhook-triggered pipeline run failed", error);
});
});
res.json({
success: true,
ok: true,
data: {
message: "Pipeline triggered",
triggeredAt: new Date().toISOString(),
@ -28,6 +37,8 @@ webhookRouter.post("/trigger", async (req: Request, res: Response) => {
});
} catch (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 { dirname, join } from "node:path";
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 express from "express";
import { apiRouter } from "./api/index";
@ -67,7 +76,7 @@ function createBasicAuthGuard() {
if (!enabled || !requiresAuth(req.method, req.path)) return next();
if (isAuthorized(req)) return next();
res.setHeader("WWW-Authenticate", 'Basic realm="Job Ops"');
res.status(401).send("Authentication required");
fail(res, unauthorized("Authentication required"));
};
return {
@ -82,16 +91,21 @@ export function createApp() {
const authGuard = createBasicAuthGuard();
app.use(cors());
app.use(requestContextMiddleware());
app.use(express.json({ limit: "5mb" }));
app.use(legacyApiResponseShim());
// Logging middleware
app.use((req, res, next) => {
const start = Date.now();
res.on("finish", () => {
const duration = Date.now() - start;
console.log(
`${req.method} ${req.path} - ${res.statusCode} (${duration}ms)`,
);
logger.info("HTTP request completed", {
method: req.method,
path: req.path,
status: res.statusCode,
durationMs: duration,
});
});
next();
});
@ -101,6 +115,7 @@ export function createApp() {
// API routes
app.use("/api", apiRouter);
app.use(notFoundApiHandler());
// Serve static files for generated PDFs
const pdfDir = join(getDataDir(), "pdfs");
@ -132,5 +147,7 @@ export function createApp() {
});
}
app.use(apiErrorHandler);
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 { logger } from "@infra/logger";
import { runWithRequestContext } from "@infra/request-context";
import type { PipelineConfig } from "@shared/types";
import { getDataDir } from "../config/dataDir";
import * as jobsRepo from "../repositories/jobs";
@ -71,91 +73,92 @@ export async function runPipeline(
const mergedConfig = { ...DEFAULT_CONFIG, ...config };
const pipelineRun = await pipelineRepo.createPipelineRun();
console.log("🚀 Starting job pipeline...");
console.log(
` Config: topN=${mergedConfig.topN}, minScore=${mergedConfig.minSuitabilityScore} (manual processing)`,
);
try {
const profile = await loadProfileStep();
const { discoveredJobs } = await discoverJobsStep({ mergedConfig });
const { created } = await importJobsStep({ discoveredJobs });
await pipelineRepo.updatePipelineRun(pipelineRun.id, {
jobsDiscovered: created,
return runWithRequestContext({ pipelineRunId: pipelineRun.id }, async () => {
const pipelineLogger = logger.child({ pipelineRunId: pipelineRun.id });
pipelineLogger.info("Starting pipeline run", {
topN: mergedConfig.topN,
minSuitabilityScore: mergedConfig.minSuitabilityScore,
sources: mergedConfig.sources,
});
const { unprocessedJobs, scoredJobs } = await scoreJobsStep({ profile });
try {
const profile = await loadProfileStep();
const jobsToProcess = selectJobsStep({
scoredJobs,
mergedConfig,
});
const { discoveredJobs } = await discoverJobsStep({ mergedConfig });
console.log("\n🏭 Auto-processing top jobs...");
console.log(
` Found ${jobsToProcess.length} candidates (score >= ${mergedConfig.minSuitabilityScore}, top ${mergedConfig.topN})`,
);
const { created } = await importJobsStep({ discoveredJobs });
const { processedCount } = await processJobsStep({
jobsToProcess,
processJob,
});
await pipelineRepo.updatePipelineRun(pipelineRun.id, {
jobsDiscovered: created,
});
await pipelineRepo.updatePipelineRun(pipelineRun.id, {
status: "completed",
completedAt: new Date().toISOString(),
jobsProcessed: processedCount,
});
const { unprocessedJobs, scoredJobs } = await scoreJobsStep({ profile });
console.log("\n🎉 Pipeline completed!");
console.log(` Jobs discovered: ${created}`);
console.log(` Jobs processed: ${processedCount}`);
const jobsToProcess = selectJobsStep({
scoredJobs,
mergedConfig,
});
progressHelpers.complete(created, processedCount);
pipelineLogger.info("Selected jobs for processing", {
candidates: jobsToProcess.length,
});
await notifyPipelineWebhookStep("pipeline.completed", {
pipelineRunId: pipelineRun.id,
jobsDiscovered: created,
jobsScored: unprocessedJobs.length,
jobsProcessed: processedCount,
});
isPipelineRunning = false;
const { processedCount } = await processJobsStep({
jobsToProcess,
processJob,
});
return {
success: true,
jobsDiscovered: created,
jobsProcessed: processedCount,
};
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
await pipelineRepo.updatePipelineRun(pipelineRun.id, {
status: "completed",
completedAt: new Date().toISOString(),
jobsProcessed: processedCount,
});
await pipelineRepo.updatePipelineRun(pipelineRun.id, {
status: "failed",
completedAt: new Date().toISOString(),
errorMessage: message,
});
progressHelpers.complete(created, processedCount);
pipelineLogger.info("Pipeline run completed", {
jobsDiscovered: created,
jobsProcessed: processedCount,
});
progressHelpers.failed(message);
await notifyPipelineWebhookStep("pipeline.completed", {
pipelineRunId: pipelineRun.id,
jobsDiscovered: created,
jobsScored: unprocessedJobs.length,
jobsProcessed: processedCount,
});
await notifyPipelineWebhookStep("pipeline.failed", {
pipelineRunId: pipelineRun.id,
error: message,
});
isPipelineRunning = false;
return {
success: true,
jobsDiscovered: created,
jobsProcessed: processedCount,
};
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
console.error("\n❌ Pipeline failed:", message);
await pipelineRepo.updatePipelineRun(pipelineRun.id, {
status: "failed",
completedAt: new Date().toISOString(),
errorMessage: message,
});
return {
success: false,
jobsDiscovered: 0,
jobsProcessed: 0,
error: message,
};
}
progressHelpers.failed(message);
pipelineLogger.error("Pipeline run failed", error);
await notifyPipelineWebhookStep("pipeline.failed", {
pipelineRunId: pipelineRun.id,
error: message,
});
return {
success: false,
jobsDiscovered: 0,
jobsProcessed: 0,
error: message,
};
} finally {
isPipelineRunning = false;
}
});
}
export type ProcessJobOptions = {
@ -172,83 +175,87 @@ export async function summarizeJob(
success: boolean;
error?: string;
}> {
console.log(`📝 Summarizing job ${jobId}...`);
return runWithRequestContext({ jobId }, async () => {
const jobLogger = logger.child({ jobId });
jobLogger.info("Summarizing job");
try {
const job = await jobsRepo.getJobById(jobId);
if (!job) return { success: false, error: "Job not found" };
try {
const job = await jobsRepo.getJobById(jobId);
if (!job) return { success: false, error: "Job not found" };
const profile = await getProfile();
const profile = await getProfile();
// 1. Generate Summary & Tailoring
let tailoredSummary = job.tailoredSummary;
let tailoredHeadline = job.tailoredHeadline;
let tailoredSkills = job.tailoredSkills;
// 1. Generate Summary & Tailoring
let tailoredSummary = job.tailoredSummary;
let tailoredHeadline = job.tailoredHeadline;
let tailoredSkills = job.tailoredSkills;
if (!tailoredSummary || !tailoredHeadline || options?.force) {
console.log(" Generating tailoring (summary, headline, skills)...");
const tailoringResult = await generateTailoring(
job.jobDescription || "",
profile,
);
if (tailoringResult.success && tailoringResult.data) {
tailoredSummary = tailoringResult.data.summary;
tailoredHeadline = tailoringResult.data.headline;
tailoredSkills = JSON.stringify(tailoringResult.data.skills);
} else if (options?.force || !tailoredSummary || !tailoredHeadline) {
return {
success: false,
error: `Tailoring failed: ${tailoringResult.error || "unknown error"}`,
};
}
}
// 2. Suggest Projects
let selectedProjectIds = job.selectedProjectIds;
if (!selectedProjectIds || options?.force) {
console.log(" Suggesting projects...");
try {
const { catalog, selectionItems } = extractProjectsFromProfile(profile);
const overrideResumeProjectsRaw = await getSetting("resumeProjects");
const { resumeProjects } = resolveResumeProjectsSettings({
catalog,
overrideRaw: overrideResumeProjectsRaw,
});
const locked = resumeProjects.lockedProjectIds;
const desiredCount = Math.max(
0,
resumeProjects.maxProjects - locked.length,
if (!tailoredSummary || !tailoredHeadline || options?.force) {
jobLogger.info("Generating tailoring content");
const tailoringResult = await generateTailoring(
job.jobDescription || "",
profile,
);
const eligibleSet = new Set(resumeProjects.aiSelectableProjectIds);
const eligibleProjects = selectionItems.filter((p) =>
eligibleSet.has(p.id),
);
const picked = await pickProjectIdsForJob({
jobDescription: job.jobDescription || "",
eligibleProjects,
desiredCount,
});
selectedProjectIds = [...locked, ...picked].join(",");
} catch (_err) {
console.warn(" ⚠️ Failed to suggest projects, leaving empty");
if (tailoringResult.success && tailoringResult.data) {
tailoredSummary = tailoringResult.data.summary;
tailoredHeadline = tailoringResult.data.headline;
tailoredSkills = JSON.stringify(tailoringResult.data.skills);
} else if (options?.force || !tailoredSummary || !tailoredHeadline) {
return {
success: false,
error: `Tailoring failed: ${tailoringResult.error || "unknown error"}`,
};
}
}
// 2. Suggest Projects
let selectedProjectIds = job.selectedProjectIds;
if (!selectedProjectIds || options?.force) {
jobLogger.info("Selecting projects");
try {
const { catalog, selectionItems } =
extractProjectsFromProfile(profile);
const overrideResumeProjectsRaw = await getSetting("resumeProjects");
const { resumeProjects } = resolveResumeProjectsSettings({
catalog,
overrideRaw: overrideResumeProjectsRaw,
});
const locked = resumeProjects.lockedProjectIds;
const desiredCount = Math.max(
0,
resumeProjects.maxProjects - locked.length,
);
const eligibleSet = new Set(resumeProjects.aiSelectableProjectIds);
const eligibleProjects = selectionItems.filter((p) =>
eligibleSet.has(p.id),
);
const picked = await pickProjectIdsForJob({
jobDescription: job.jobDescription || "",
eligibleProjects,
desiredCount,
});
selectedProjectIds = [...locked, ...picked].join(",");
} catch (error) {
jobLogger.warn("Failed to suggest projects", error);
}
}
await jobsRepo.updateJob(job.id, {
tailoredSummary: tailoredSummary ?? undefined,
tailoredHeadline: tailoredHeadline ?? undefined,
tailoredSkills: tailoredSkills ?? undefined,
selectedProjectIds: selectedProjectIds ?? undefined,
});
return { success: true };
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
jobLogger.error("Summarization failed", error);
return { success: false, error: message };
}
await jobsRepo.updateJob(job.id, {
tailoredSummary: tailoredSummary ?? undefined,
tailoredHeadline: tailoredHeadline ?? undefined,
tailoredSkills: tailoredSkills ?? undefined,
selectedProjectIds: selectedProjectIds ?? undefined,
});
return { success: true };
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
return { success: false, error: message };
}
});
}
/**
@ -261,43 +268,46 @@ export async function generateFinalPdf(
success: boolean;
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 {
const job = await jobsRepo.getJobById(jobId);
if (!job) return { success: false, error: "Job not found" };
try {
const job = await jobsRepo.getJobById(jobId);
if (!job) return { success: false, error: "Job not found" };
// Mark as processing
await jobsRepo.updateJob(job.id, { status: "processing" });
// Mark as processing
await jobsRepo.updateJob(job.id, { status: "processing" });
const pdfResult = await generatePdf(
job.id,
{
summary: job.tailoredSummary || "",
headline: job.tailoredHeadline || "",
skills: job.tailoredSkills ? JSON.parse(job.tailoredSkills) : [],
},
job.jobDescription || "",
undefined, // deprecated baseResumePath parameter
job.selectedProjectIds,
);
const pdfResult = await generatePdf(
job.id,
{
summary: job.tailoredSummary || "",
headline: job.tailoredHeadline || "",
skills: job.tailoredSkills ? JSON.parse(job.tailoredSkills) : [],
},
job.jobDescription || "",
undefined, // deprecated baseResumePath parameter
job.selectedProjectIds,
);
if (!pdfResult.success) {
// Revert status if failed
await jobsRepo.updateJob(job.id, { status: "discovered" });
return { success: false, error: pdfResult.error };
}
if (!pdfResult.success) {
// Revert status if failed
await jobsRepo.updateJob(job.id, { status: "discovered" });
return { success: false, error: pdfResult.error };
await jobsRepo.updateJob(job.id, {
status: "ready",
pdfPath: pdfResult.pdfPath,
});
return { success: true };
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
jobLogger.error("PDF generation failed", error);
return { success: false, error: message };
}
await jobsRepo.updateJob(job.id, {
status: "ready",
pdfPath: pdfResult.pdfPath,
});
return { success: true };
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
return { success: false, error: message };
}
});
}
/**

View File

@ -1,3 +1,5 @@
import { logger } from "@infra/logger";
/**
* Pipeline progress tracking with Server-Sent Events.
*/
@ -67,7 +69,7 @@ export function updateProgress(update: Partial<PipelineProgress>): void {
try {
listener(currentProgress);
} 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 * as jobsRepo from "../../repositories/jobs";
import * as settingsRepo from "../../repositories/settings";
@ -12,7 +13,7 @@ export async function discoverJobsStep(args: {
discoveredJobs: CreateJobInput[];
sourceErrors: string[];
}> {
console.log("\n🕷 Running crawler...");
logger.info("Running discovery step");
progressHelpers.startCrawling();
const discoveredJobs: CreateJobInput[] = [];
@ -149,7 +150,7 @@ export async function discoverJobsStep(args: {
}
if (sourceErrors.length > 0) {
console.warn(`⚠️ Some sources failed: ${sourceErrors.join("; ")}`);
logger.warn("Some discovery sources failed", { sourceErrors });
}
progressHelpers.crawlingComplete(discoveredJobs.length);

View File

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

View File

@ -1,10 +1,11 @@
import { logger } from "@infra/logger";
import { getProfile } from "../../services/profile";
export async function loadProfileStep(): Promise<Record<string, unknown>> {
console.log("\n📋 Loading profile...");
logger.info("Loading profile");
return getProfile().catch((error) => {
console.warn(
"⚠️ Failed to load profile for scoring, using empty profile:",
logger.warn(
"Failed to load profile for scoring, using empty profile",
error,
);
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";
export async function notifyPipelineWebhookStep(
@ -22,22 +24,30 @@ export async function notifyPipelineWebhookStep(
const secret = process.env.WEBHOOK_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, {
method: "POST",
headers,
body: JSON.stringify({
event,
sentAt: new Date().toISOString(),
...payload,
}),
body: JSON.stringify(sanitizedPayload),
});
if (!response.ok) {
console.warn(
`⚠️ Pipeline webhook POST failed (${response.status}): ${await response.text()}`,
);
const responseText = await response.text().catch(() => "");
logger.warn("Pipeline webhook POST failed", {
status: response.status,
error: responseText.slice(0, 200),
});
}
} 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 type { ScoredJob } from "./types";
@ -28,7 +29,10 @@ export async function processJobsStep(args: {
if (result.success) {
processedCount++;
} 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);

View File

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

View File

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

View File

@ -1,3 +1,5 @@
import { logger } from "@infra/logger";
export function parseJsonContent<T>(content: string, jobId?: string): T {
let candidate = content.trim();
@ -16,10 +18,10 @@ export function parseJsonContent<T>(content: string, jobId?: string): T {
try {
return JSON.parse(candidate) as T;
} catch (error) {
console.error(
`❌ [${jobId ?? "unknown"}] Failed to parse JSON:`,
candidate.substring(0, 200),
);
logger.error("Failed to parse LLM JSON content", {
jobId: jobId ?? "unknown",
sample: candidate.substring(0, 200),
});
throw new Error(
`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.
*/
import { logger } from "@infra/logger";
import type { ManualJobDraft } from "@shared/types";
import { getSetting } from "../repositories/settings";
import { type JsonSchemaDefinition, LlmService } from "./llm-service";
@ -111,7 +112,7 @@ export async function inferManualJobDetails(
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 {
job: {},
warning: "AI inference failed. Fill details manually.",

View File

@ -7,6 +7,7 @@
import type { ResumeData } from "@shared/rxresume-schema";
type AnyObj = Record<string, unknown>;
const MAX_ERROR_SNIPPET = 300;
const TOKEN_COOKIE_NAMES = [
"accessToken",
@ -241,8 +242,10 @@ export class RxResumeClient {
});
if (!res.ok) {
const text = await res.text();
throw new Error(`Login failed: HTTP ${res.status} ${text}`);
const text = await res.text().catch(() => "");
throw new Error(
`Login failed: HTTP ${res.status} ${sanitizeResponseSnippet(text)}`,
);
}
const data = (await res.json()) as AnyObj;
@ -266,7 +269,7 @@ export class RxResumeClient {
if (!token || typeof token !== "string") {
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) {
const text = await res.text();
throw new Error(`Create failed: HTTP ${res.status} ${text}`);
const text = await res.text().catch(() => "");
throw new Error(
`Create failed: HTTP ${res.status} ${sanitizeResponseSnippet(text)}`,
);
}
const d = (await res.json()) as AnyObj;
@ -310,7 +315,7 @@ export class RxResumeClient {
if (!id || typeof id !== "string") {
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) {
const text = await res.text();
throw new Error(`Print failed: HTTP ${res.status} ${text}`);
const text = await res.text().catch(() => "");
throw new Error(
`Print failed: HTTP ${res.status} ${sanitizeResponseSnippet(text)}`,
);
}
const d = (await res.json()) as AnyObj;
@ -348,9 +355,7 @@ export class RxResumeClient {
(d?.result as AnyObj)?.href;
if (!url || typeof url !== "string") {
throw new Error(
`Print succeeded but could not locate URL in response. Response: ${JSON.stringify(d)}`,
);
throw new Error("Print succeeded but could not locate URL in response.");
}
return url;
@ -372,8 +377,10 @@ export class RxResumeClient {
);
if (!res.ok && res.status !== 204) {
const text = await res.text();
throw new Error(`Delete failed: HTTP ${res.status} ${text}`);
const text = await res.text().catch(() => "");
throw new Error(
`Delete failed: HTTP ${res.status} ${sanitizeResponseSnippet(text)}`,
);
}
}
@ -416,8 +423,10 @@ export class RxResumeClient {
});
if (!res.ok) {
const text = await res.text();
throw new Error(`List resumes failed: HTTP ${res.status} ${text}`);
const text = await res.text().catch(() => "");
throw new Error(
`List resumes failed: HTTP ${res.status} ${sanitizeResponseSnippet(text)}`,
);
}
const data = (await res.json()) as AnyObj | AnyObj[];
@ -445,3 +454,9 @@ export class RxResumeClient {
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.
*/
import { logger } from "@infra/logger";
import type { Job } from "@shared/types";
import { getSetting } from "../repositories/settings";
import { type JsonSchemaDefinition, LlmService } from "./llm-service";
@ -50,7 +51,7 @@ export async function scoreJobSuitability(
process.env.MODEL ||
"google/gemini-3-flash-preview";
const prompt = buildScoringPrompt(job, profile);
const prompt = buildScoringPrompt(job, sanitizeProfileForPrompt(profile));
const llm = new LlmService();
const result = await llm.callJson<{ score: number; reason: string }>({
@ -63,11 +64,12 @@ export async function scoreJobSuitability(
if (!result.success) {
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(
`❌ [Job ${job.id}] Scoring failed: ${result.error}, using mock scoring`,
);
logger.error("Scoring failed, using mock scoring", {
jobId: job.id,
error: result.error,
});
return mockScore(job);
}
@ -75,9 +77,9 @@ export async function scoreJobSuitability(
// Validate we got a reasonable response
if (typeof score !== "number" || Number.isNaN(score)) {
console.error(
`❌ [Job ${job.id}] Invalid score in response, using mock scoring`,
);
logger.error("Invalid score in AI response, using mock scoring", {
jobId: job.id,
});
return mockScore(job);
}
@ -173,21 +175,19 @@ export function parseJsonFromContent(
const reason = reasonMatch
? reasonMatch[1].trim().replace(controlCharsRegex, "")
: "Score extracted from malformed response";
console.log(
`⚠️ [Job ${jobId || "unknown"}] Parsed score via regex fallback: ${score}`,
);
logger.warn("Parsed score via regex fallback", {
jobId: jobId || "unknown",
score,
});
return { score, reason };
}
// Log the failure with full content for debugging
console.error(
`❌ [Job ${jobId || "unknown"}] Failed to parse AI response. Raw content (first 500 chars):`,
originalContent.substring(0, 500),
);
console.error(
` Sanitized content (first 500 chars):`,
sanitized.substring(0, 500),
);
logger.error("Failed to parse AI response", {
jobId: jobId || "unknown",
rawSample: originalContent.substring(0, 500),
sanitizedSample: sanitized.substring(0, 500),
});
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."}`;
}
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 {
// Simple keyword-based scoring as fallback
const jd = (job.jobDescription || "").toLowerCase();

View File

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

View File

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

View File

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

View File

@ -10,8 +10,8 @@
"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:ukvisajobs": "npm --workspace ukvisajobs-extractor run check:types",
"check:all": "npx biome ci .",
"format:all": "npx biome format . --write",
"check:all": "./orchestrator/node_modules/.bin/biome ci .",
"format:all": "./orchestrator/node_modules/.bin/biome format . --write",
"check:types:gradcracker": "npm --workspace gradcracker-extractor run check:types"
},
"devDependencies": {

View File

@ -309,12 +309,28 @@ export interface PipelineRun {
}
// API Response types
export interface ApiResponse<T> {
success: boolean;
data?: T;
error?: string;
export interface ApiMeta {
requestId: 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 {
jobs: Job[];
total: number;