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:
parent
82b261c7bc
commit
16a8f1d15a
58
AGENTS.md
Normal file
58
AGENTS.md
Normal 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.
|
||||||
@ -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.
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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 });
|
||||||
|
|||||||
@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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();
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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));
|
||||||
|
|||||||
@ -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 });
|
||||||
|
|||||||
@ -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");
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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"],
|
||||||
|
|||||||
@ -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 } });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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");
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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 });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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 } });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
128
orchestrator/src/server/infra/errors.ts
Normal file
128
orchestrator/src/server/infra/errors.ts
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
154
orchestrator/src/server/infra/http.ts
Normal file
154
orchestrator/src/server/infra/http.ts
Normal 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);
|
||||||
|
};
|
||||||
74
orchestrator/src/server/infra/logger.ts
Normal file
74
orchestrator/src/server/infra/logger.ts
Normal 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();
|
||||||
30
orchestrator/src/server/infra/request-context.ts
Normal file
30
orchestrator/src/server/infra/request-context.ts
Normal 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;
|
||||||
|
}
|
||||||
114
orchestrator/src/server/infra/sanitize.ts
Normal file
114
orchestrator/src/server/infra/sanitize.ts
Normal 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
|
||||||
|
>;
|
||||||
|
}
|
||||||
@ -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 };
|
||||||
}
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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);
|
||||||
|
|
||||||
|
|||||||
@ -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>;
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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";
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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"}`,
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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.",
|
||||||
|
|||||||
@ -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);
|
||||||
|
}
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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/*"]
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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"),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@ -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": {
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user