Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,20 @@ SCRAPER_URL=http://localhost:8081
# Endereço em que o próprio scraper-go escuta (opcional, default :8081).
GO_SCRAPER_ADDR=:8081

# ---------------------------------------------------------------------------
# ATS Forge — microserviço de geração de currículos (DOCX/PDF)
# ---------------------------------------------------------------------------
# URL base do serviço ats-forge (porta 8089). Usada pelo módulo resume do
# backend (POST /resume/generate) como caminho server-side opcional.
ATS_FORGE_URL=http://localhost:8089
# Segredo compartilhado enviado no header x-api-key. Deixe vazio em dev para
# desabilitar a autenticação serviço-a-serviço; defina em produção.
# OBS: o frontend chama o ats-forge diretamente, então mantenha vazio se o
# navegador for o chamador (não exponha segredos no frontend).
ATS_FORGE_API_KEY=
# URL do ats-forge usada pelo FRONTEND (navegador) para gerar currículos.
VITE_ATS_FORGE_URL=http://localhost:8089

# ---------------------------------------------------------------------------
# Observabilidade
# ---------------------------------------------------------------------------
Expand Down
979 changes: 979 additions & 0 deletions SECURITY_AUDIT_LINEAR_CARDS.md

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions backend/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import adminRoutes from "./routes/admin.routes";
import { jobsRoutes } from "./routes/jobs.routes";
import { keywordsRoutes } from "./routes/keywords.routes";
import { notificationsRoutes } from "./routes/notifications.routes";
import { resumeRoutes } from "./routes/resume.routes";
import { savedJobsRoutes } from "./routes/savedJobs.routes";
import superAdminRoutes from "./routes/superAdmin.routes";
import supportRoutes from "./routes/support.routes";
Expand Down Expand Up @@ -44,6 +45,7 @@ export function createJobsApiApp() {
apiV1.use("/keywords", withSession, requireAuth, keywordsRoutes);
apiV1.use("/notifications", withSession, requireAuth, notificationsRoutes);
apiV1.use("/saved-jobs", withSession, requireAuth, savedJobsRoutes);
apiV1.use("/resume", withSession, requireAuth, resumeRoutes);
apiV1.use("/admin", withSession, supportRoutes);
apiV1.use("/admin", withSession, adminRoutes);
apiV1.use("/admin", withSession, superAdminRoutes);
Expand All @@ -57,6 +59,7 @@ export function createJobsApiApp() {
app.use("/keywords", withSession, requireAuth, keywordsRoutes);
app.use("/notifications", withSession, requireAuth, notificationsRoutes);
app.use("/saved-jobs", withSession, requireAuth, savedJobsRoutes);
app.use("/resume", withSession, requireAuth, resumeRoutes);
app.use("/admin", withSession, supportRoutes);
app.use("/admin", withSession, adminRoutes);
app.use("/admin", withSession, superAdminRoutes);
Expand Down
2 changes: 2 additions & 0 deletions backend/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ function parseBoolean(value: string | undefined, fallback: boolean): boolean {
export const config = {
scraperUrl: process.env.SCRAPER_URL ?? "http://scraper-go:8081",
prometheusUrl: process.env.PROMETHEUS_URL ?? "http://prometheus:9090",
atsForgeUrl: process.env.ATS_FORGE_URL ?? "http://ats-forge:8089",
atsForgeApiKey: process.env.ATS_FORGE_API_KEY?.trim() ?? "",
};

function parseNumber(value: string | undefined, fallback: number): number {
Expand Down
112 changes: 112 additions & 0 deletions backend/src/modules/resume/resume.client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import { config } from "../../config";
import { AppError } from "../../lib/errors";
import type {
AtsReport,
GeneratedResume,
JobTarget,
NormalizedProfile,
ResumeAnalysis,
ResumeFormat,
ResumeSources,
} from "./resume.types";

// GitHub enrichment happens inside ats-forge, so allow a longer budget.
const TIMEOUT_MS = 20000;

export interface GenerateResumeRequest {
profile: NormalizedProfile;
job?: JobTarget | null;
sources?: ResumeSources | null;
about?: string | null;
format: ResumeFormat;
filename?: string;
}

function decodeReport(headerValue: string | null): AtsReport | null {
if (!headerValue) return null;
try {
const json = Buffer.from(headerValue, "base64").toString("utf-8");
return JSON.parse(json) as AtsReport;
} catch {
return null;
}
}

function filenameFromDisposition(
disposition: string | null,
fallback: string,
): string {
if (!disposition) return fallback;
const match = /filename="?([^"]+)"?/i.exec(disposition);
return match?.[1] ?? fallback;
}

/**
* Thin HTTP client for the ats-forge resume microservice. Mirrors the
* `scraperClient` pattern: a single `request` helper with a timeout that maps
* transport/HTTP failures onto `AppError`.
*/
function buildHeaders(): Record<string, string> {
const headers: Record<string, string> = { "Content-Type": "application/json" };
if (config.atsForgeApiKey) headers["x-api-key"] = config.atsForgeApiKey;
return headers;
}

async function postToAtsForge(
path: string,
payload: GenerateResumeRequest,
): Promise<Response> {
let response: Response;
try {
response = await fetch(`${config.atsForgeUrl}${path}`, {
method: "POST",
headers: buildHeaders(),
body: JSON.stringify(payload),
signal: AbortSignal.timeout(TIMEOUT_MS),
});
} catch (err) {
throw AppError.internal(
"Não foi possível contatar o serviço de geração de currículos.",
{ cause: (err as Error).message },
);
}

if (response.status === 400) {
const body = await response.json().catch(() => null);
throw AppError.validation(
body?.message ?? "Dados insuficientes para gerar o currículo.",
body?.details,
);
}

if (!response.ok) {
throw AppError.internal(
`Falha ao gerar currículo (ats-forge respondeu HTTP ${response.status}).`,
);
}

return response;
}

export const resumeClient = {
async generate(payload: GenerateResumeRequest): Promise<GeneratedResume> {
const response = await postToAtsForge("/resumes/generate", payload);

const arrayBuffer = await response.arrayBuffer();
return {
content: Buffer.from(arrayBuffer),
contentType:
response.headers.get("content-type") ?? "application/octet-stream",
filename: filenameFromDisposition(
response.headers.get("content-disposition"),
`${payload.filename ?? "curriculo"}.${payload.format}`,
),
atsReport: decodeReport(response.headers.get("x-ats-report")),
};
},

async analyze(payload: GenerateResumeRequest): Promise<ResumeAnalysis> {
const response = await postToAtsForge("/resumes/analyze", payload);
return (await response.json()) as ResumeAnalysis;
},
};
103 changes: 103 additions & 0 deletions backend/src/modules/resume/resume.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { Request, Response } from "express";
import { getIronSession } from "iron-session";
import { AppError } from "../../lib/errors";
import { sessionOptions } from "../../lib/session";
import { Session } from "../types/auth.types";
import { ResumeService } from "./resume.service";
import type { JobTarget } from "./resume.types";

export class ResumeController {
constructor(private readonly service: ResumeService) {}

private async getSession(req: Request, res: Response) {
return getIronSession<Session>(req, res, sessionOptions);
}

private async requireUserId(req: Request, res: Response): Promise<string> {
const session = await this.getSession(req, res);
if (!session.userId) {
throw AppError.unauthorized();
}
return session.userId;
}

private parseParams(req: Request) {
const {
format,
jobTitle,
jobDescription,
jobUrl,
language,
githubUrl,
linkedinUrl,
about,
experiences,
} = req.body as {
format: "docx" | "pdf" | "md";
jobTitle?: string;
jobDescription?: string;
jobUrl?: string;
language?: string;
githubUrl?: string;
linkedinUrl?: string;
about?: string;
experiences?: Array<{
company: string;
role: string;
period?: string;
description?: string;
stack?: string[];
}>;
};

const job: JobTarget | null =
jobTitle || jobDescription || jobUrl
? { title: jobTitle, description: jobDescription, url: jobUrl, language }
: null;

const sources =
githubUrl || linkedinUrl
? { github: githubUrl, linkedin: linkedinUrl }
: null;

return {
format,
job,
sources,
about: about ?? null,
experiences: experiences ?? null,
};
}

// POST /resume/analyze
async analyze(req: Request, res: Response) {
const userId = await this.requireUserId(req, res);
const analysis = await this.service.analyzeForUser(userId, this.parseParams(req));
return res.status(200).json(analysis);
}

// POST /resume/generate
async generate(req: Request, res: Response) {
const userId = await this.requireUserId(req, res);
const resume = await this.service.generateForUser(userId, this.parseParams(req));

res.setHeader("Content-Type", resume.contentType);
res.setHeader(
"Content-Disposition",
`attachment; filename="${resume.filename}"`,
);
if (resume.atsReport) {
res.setHeader("X-Ats-Score", String(resume.atsReport.score));
res.setHeader(
"X-Ats-Report",
Buffer.from(JSON.stringify(resume.atsReport), "utf-8").toString("base64"),
);
res.setHeader(
"Access-Control-Expose-Headers",
"X-Ats-Score, X-Ats-Report, Content-Disposition",
);
}

return res.status(200).send(resume.content);
}
}
77 changes: 77 additions & 0 deletions backend/src/modules/resume/resume.mapper.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import type { PublicUser } from "../users/users.mapper";
import type { NormalizedProfile } from "./resume.types";

type TechExperience = { name: string; years: number };

function isTechExperience(value: unknown): value is TechExperience {
return (
typeof value === "object" &&
value !== null &&
typeof (value as TechExperience).name === "string" &&
typeof (value as TechExperience).years === "number"
);
}

function resolveName(user: PublicUser): string {
if (user.displayName?.trim()) return user.displayName.trim();
const full = [user.firstName, user.lastName]
.filter((p): p is string => Boolean(p && p.trim()))
.join(" ")
.trim();
if (full) return full;
if (user.username?.trim()) return user.username.trim();
return "Candidato";
}

/**
* Maps the candidate's decrypted profile (the `users` row) into the normalized
* profile the ats-forge engine consumes. Only data the candidate actually
* provided is forwarded — every field is tagged `source: "candidate"` and
* nothing is fabricated (integration spec §7).
*/
export function toNormalizedProfile(user: PublicUser): NormalizedProfile {
const techExperiences: TechExperience[] = Array.isArray(user.technologyExperiences)
? user.technologyExperiences.filter(isTechExperience)
: [];
const yearsByTech = new Map(
techExperiences.map((t) => [t.name.toLowerCase(), t.years]),
);

const technologies = Array.isArray(user.technologies) ? user.technologies : [];

const skills: NormalizedProfile["skills"] = technologies.map((name) => ({
name,
years: yearsByTech.get(name.toLowerCase()),
category: "Competências",
source: "candidate" as const,
}));

// Include tech experiences that are not already covered by `technologies`.
for (const te of techExperiences) {
if (!technologies.some((t) => t.toLowerCase() === te.name.toLowerCase())) {
skills.push({
name: te.name,
years: te.years,
category: "Competências",
source: "candidate",
});
}
}

return {
name: resolveName(user),
headline: user.level?.trim() || undefined,
// Summary intentionally omitted: the engine derives an honest summary from
// the candidate's own evidence instead of inventing one.
contact: {
email: user.email?.trim() || undefined,
phone: user.phone?.trim() || undefined,
},
experience: [],
education: [],
skills,
projects: [],
links: [],
languages: [],
};
}
Loading
Loading