Skip to content

Commit 915650a

Browse files
authored
Feacture/issue-19-keywords-control (#38)
## New Keyword modal Added a modal to specify the keywords for scraping, with two new routes, get and post to "api/keywords", returning a list of words when sent. These are being stored locally in a JSON file. ``` bash backend/ ├── src/ │ └── db/ │ └── environment.json ```
2 parents 180ac2d + 80487a9 commit 915650a

10 files changed

Lines changed: 476 additions & 69 deletions

File tree

backend/src/config.js

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
import { existsSync, readFileSync } from "fs";
2+
import path from "path";
3+
14
const DEFAULT_KEYWORDS = [
25
"Java",
36
"JavaScript",
@@ -33,16 +36,30 @@ function parseNumber(value, fallback) {
3336
}
3437

3538
function parseKeywords(value) {
36-
if (!value) {
37-
return DEFAULT_KEYWORDS;
39+
// Tenta pegar do arquivo environment.json
40+
try {
41+
const envPath = path.resolve(process.cwd(), "src", "db", "environment.json");
42+
if (existsSync(envPath)) {
43+
const data = JSON.parse(readFileSync(envPath, "utf-8"));
44+
if (Array.isArray(data.KEYWORDS) && data.KEYWORDS.length > 0) {
45+
return data.KEYWORDS;
46+
}
47+
}
48+
} catch (err) {
49+
// Se falhar, fallback
3850
}
3951

40-
const keywords = value
41-
.split(",")
42-
.map((item) => item.trim())
43-
.filter(Boolean);
52+
// Tenta pegar da variavel de ambiente
53+
if (value) {
54+
const keywords = value
55+
.split(",")
56+
.map((item) => item.trim())
57+
.filter(Boolean);
58+
if (keywords.length > 0) return keywords;
59+
}
4460

45-
return keywords.length > 0 ? keywords : DEFAULT_KEYWORDS;
61+
// Fallback para as keywords padrao
62+
return DEFAULT_KEYWORDS;
4663
}
4764

4865
function parseTimeFilter(value, fallback) {

backend/src/db/environment.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"KEYWORDS": [
3+
"Java",
4+
"Spring",
5+
"RabbitMQ",
6+
"Docker"
7+
]
8+
}

backend/src/jobsApiApp.js

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import cors from "cors";
22
import express from "express";
3-
import { existsSync, mkdirSync, readdirSync, statSync } from "fs";
3+
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "fs";
44
import path from "path";
55
import XLSX from "xlsx";
66
import { run as runScraper } from "./app.js";
@@ -17,6 +17,7 @@ export function createJobsApiApp(options = {}) {
1717
let activeScraperRun = null;
1818

1919
app.use(cors());
20+
app.use(express.json());
2021

2122
function listXlsxFiles() {
2223
// Garante que a pasta exista tambem no primeiro boot em ambiente Docker.
@@ -128,6 +129,66 @@ export function createJobsApiApp(options = {}) {
128129
}
129130
});
130131

132+
app.post("/api/keywords", (req, res) => {
133+
try {
134+
const { keywords } = req.body;
135+
136+
if (!Array.isArray(keywords)) {
137+
return res.status(400).json({
138+
message: "O campo 'keywords' deve ser um array de strings.",
139+
});
140+
}
141+
142+
const envPath = path.resolve(process.cwd(), "src", "db", "environment.json");
143+
let envData = { KEYWORDS: [] };
144+
145+
if (existsSync(envPath)) {
146+
try {
147+
envData = JSON.parse(readFileSync(envPath, "utf-8"));
148+
} catch (e) {
149+
// Se o arquivo estiver corrompido, reinicia com o objeto padrao
150+
}
151+
}
152+
153+
envData.KEYWORDS = keywords;
154+
writeFileSync(envPath, JSON.stringify(envData, null, 2), "utf-8");
155+
156+
return res.json({
157+
ok: true,
158+
message: "Keywords atualizadas com sucesso.",
159+
keywords: envData.KEYWORDS,
160+
});
161+
} catch (error) {
162+
return res.status(500).json({
163+
message: "Erro ao salvar keywords.",
164+
error: error?.message || "Erro desconhecido",
165+
});
166+
}
167+
});
168+
169+
app.get("/api/keywords", (req, res) => {
170+
try {
171+
const envPath = path.resolve(process.cwd(), "src", "db", "environment.json");
172+
173+
if (!existsSync(envPath)) {
174+
return res.json({ keywords: [] });
175+
}
176+
177+
const fileContent = readFileSync(envPath, "utf-8");
178+
const envData = JSON.parse(fileContent);
179+
180+
return res.json({
181+
ok: true,
182+
keywords: envData.KEYWORDS || [],
183+
});
184+
} catch (error) {
185+
return res.status(500).json({
186+
message: "Erro ao buscar keywords.",
187+
error: error?.message || "Erro desconhecido",
188+
});
189+
}
190+
});
191+
131192
app.post("/api/scraper/run", async (_req, res) => {
132193
if (activeScraperRun) {
133194
return res.status(409).json({

backend/tests/integration/jobsApi.test.js

Lines changed: 35 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -33,14 +33,14 @@ describe("jobs API", () => {
3333
const res = await request(app).get("/api/health").expect(200);
3434
expect(res.body).toEqual({ ok: true });
3535
});
36-
36+
3737
it("GET /api/jobs/files retorna lista vazia sem xlsx", async () => {
3838
tmpDir = mkdtempSync(join(tmpdir(), "jobs-api-"));
3939
const app = createJobsApiApp({ outputDir: tmpDir });
4040
const res = await request(app).get("/api/jobs/files").expect(200);
4141
expect(res.body.files).toEqual([]);
4242
});
43-
43+
4444
it("GET /api/jobs retorna 404 quando nao ha planilhas", async () => {
4545
tmpDir = mkdtempSync(join(tmpdir(), "jobs-api-"));
4646
const app = createJobsApiApp({ outputDir: tmpDir });
@@ -72,7 +72,7 @@ describe("jobs API", () => {
7272
const sheet = XLSX.utils.json_to_sheet([{ titulo: "x" }]);
7373
XLSX.utils.book_append_sheet(workbook, sheet, "Vagas");
7474
XLSX.writeFile(workbook, join(tmpDir, "a.xlsx"));
75-
75+
7676
const app = createJobsApiApp({ outputDir: tmpDir });
7777
const res = await request(app).get("/api/jobs").query({ file: "nao-existe.xlsx" }).expect(404);
7878
expect(res.body.message).toBe("Arquivo solicitado nao encontrado.");
@@ -87,7 +87,7 @@ describe("jobs API", () => {
8787

8888
const app = createJobsApiApp({ outputDir: tmpDir });
8989
const res = await request(app).post("/api/scraper/run").expect(200);
90-
90+
9191
expect(mocks.run).toHaveBeenCalledTimes(1);
9292
expect(res.body.ok).toBe(true);
9393
expect(res.body.file).toBe("resultado.xlsx");
@@ -102,10 +102,10 @@ describe("jobs API", () => {
102102
new Promise((resolve) => {
103103
finishRun = resolve;
104104
}),
105-
);
106-
107-
const app = createJobsApiApp({ outputDir: tmpDir });
108-
105+
);
106+
107+
const app = createJobsApiApp({ outputDir: tmpDir });
108+
109109
const firstRequest = new Promise((resolve, reject) => {
110110
request(app)
111111
.post("/api/scraper/run")
@@ -140,4 +140,31 @@ describe("jobs API", () => {
140140
expect(res.body.message).toBe("Erro ao executar o scraper.");
141141
expect(res.body.error).toBe("falha no scraper");
142142
});
143+
144+
it("POST /api/keywords retorna 200 quando salvar as keywords", async () => {
145+
tmpDir = mkdtempSync(join(tmpdir(), "jobs-api-"));
146+
mocks.run.mockRejectedValue(new Error("Erro em pegar Keywords"));
147+
148+
const app = createJobsApiApp({ outputDir: tmpDir });
149+
const payload = { keywords: ["Java","Spring","RabbitMQ","Docker"] };
150+
151+
const res = await request(app).post("/api/keywords").send(payload).expect(200);
152+
153+
expect(res.body).toEqual({
154+
ok: true,
155+
message: "Keywords atualizadas com sucesso.",
156+
keywords: ["Java","Spring","RabbitMQ","Docker"]
157+
});
158+
});
159+
160+
it("GET /api/keywords retorna 200 com as keywords", async () => {
161+
const app = createJobsApiApp({ outputDir: tmpDir });
162+
const res = await request(app)
163+
.get("/api/keywords").expect(200);
164+
expect(res.body).toEqual({
165+
ok: true,
166+
keywords: ["Java","Spring","RabbitMQ","Docker"]
167+
});
168+
});
169+
143170
});

backend/tests/unit/utils/config.test.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,9 +45,9 @@ describe("getConfig", () => {
4545
});
4646

4747
it("parseia SEARCH_KEYWORDS em lista", () => {
48-
vi.stubEnv("SEARCH_KEYWORDS", "UX Designer, Product Owner ");
48+
vi.stubEnv("SEARCH_KEYWORDS", "Java","Spring","RabbitMQ","Docker");
4949
const config = getConfig();
50-
expect(config.keywords).toEqual(["UX Designer", "Product Owner"]);
50+
expect(config.keywords).toEqual(["Java","Spring","RabbitMQ","Docker"]);
5151
});
5252

5353
it("rejeita TIME_FILTER invalido e usa fallback", () => {

frontend/src/components/JobsFiltersCard.tsx

Lines changed: 60 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,12 @@ import { Badge } from "@/components/ui/badge";
22
import { Card, CardContent } from "@/components/ui/card";
33
import { Input } from "@/components/ui/input";
44
import type { JobFile, JobsMeta } from "@/types/jobs";
5+
import { KeywordsModal } from "./KeywordsModal";
56
import { Search } from "lucide-react";
6-
import type { Dispatch, ReactNode, SetStateAction } from "react";
7+
import { useState, Dispatch, ReactNode, SetStateAction } from "react";
78

89
import { BriefcaseBusiness, FileSpreadsheet } from "lucide-react";
10+
import { Button } from "./ui/button";
911
interface JobsFiltersCardProps {
1012
search: string;
1113
setSearch: Dispatch<SetStateAction<string>>;
@@ -31,64 +33,72 @@ export function JobsFiltersCard({
3133
meta,
3234
actions,
3335
}: JobsFiltersCardProps) {
36+
const [seeKeywordsModal, setSeeKeywordsModal] = useState(false);
37+
3438
return (
35-
<Card className="border-border/70 bg-card/85 backdrop-blur dark:bg-card/95">
36-
<CardContent className="flex flex-col gap-3 pt-6">
37-
{/* Campo de busca */}
38-
<div className="flex items-center gap-3">
39-
<div className="relative flex-1">
40-
<Search className="pointer-events-none absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
41-
<Input
42-
value={search}
43-
onChange={(event) => setSearch(event.target.value)}
44-
className="pl-9 w-full"
45-
placeholder="Buscar por titulo, empresa, local ou link"
46-
/>
39+
<>
40+
<Card className="border-border/70 bg-card/85 backdrop-blur dark:bg-card/95">
41+
<CardContent className="flex flex-col gap-3 pt-6">
42+
{/* Campo de busca */}
43+
<div className="flex items-center gap-3">
44+
<div className="relative flex-1">
45+
<Search className="pointer-events-none absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
46+
<Input
47+
value={search}
48+
onChange={(event) => setSearch(event.target.value)}
49+
className="pl-9 w-full"
50+
placeholder="Buscar por titulo, empresa, local ou link"
51+
/>
52+
</div>
53+
<div className="flex items-center gap-2">{actions}</div>
4754
</div>
48-
<div className="flex items-center gap-2">{actions}</div>
49-
</div>
50-
51-
{/* Seção de filtros */}
52-
<div className="flex items-center gap-2 flex">
53-
<select
54-
className="h-10 rounded-md border border-input bg-background px-3 text-sm focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring"
55-
value={keywordFilter}
56-
onChange={(event) => setKeywordFilter(event.target.value)}
57-
>
58-
<option value="all">Todas as palavras-chave</option>
59-
{keywords.map((keyword) => (
60-
<option key={keyword} value={keyword}>
61-
{keyword}
62-
</option>
63-
))}
64-
</select>
6555

66-
<div>
56+
{/* Seção de filtros */}
57+
<div className="flex items-center gap-2">
6758
<select
68-
className="h-10 w-full rounded-md border border-input bg-background px-3 text-sm focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring"
69-
value={selectedFile}
70-
onChange={(event) => setSelectedFile(event.target.value)}
59+
className="h-10 rounded-md border border-input bg-background px-3 text-sm focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring"
60+
value={keywordFilter}
61+
onChange={(event) => setKeywordFilter(event.target.value)}
7162
>
72-
{files.map((file) => (
73-
<option key={file.file} value={file.file}>
74-
{file.file}
63+
<option value="all">Todas as palavras-chave</option>
64+
{keywords.map((keyword) => (
65+
<option key={keyword} value={keyword}>
66+
{keyword}
7567
</option>
7668
))}
7769
</select>
78-
</div>
7970

80-
<div className="flex items-center gap-2">
81-
<Badge variant="secondary" className="gap-1 text-xs">
82-
<FileSpreadsheet className="h-3.5 w-3.5" />
83-
{meta.file || "Sem arquivo"}
84-
</Badge>
85-
<Badge className="gap-1 text-xs">
86-
<BriefcaseBusiness className="h-3.5 w-3.5" />
87-
{meta.total} vagas
88-
</Badge>
71+
<div>
72+
<select
73+
className="h-10 w-full rounded-md border border-input bg-background px-3 text-sm focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring"
74+
value={selectedFile}
75+
onChange={(event) => setSelectedFile(event.target.value)}
76+
>
77+
{files.map((file) => (
78+
<option key={file.file} value={file.file}>
79+
{file.file}
80+
</option>
81+
))}
82+
</select>
83+
</div>
84+
85+
<div className="flex justify-between w-full">
86+
<div className="flex items-center gap-2">
87+
<Badge variant="secondary" className="gap-1 text-xs">
88+
<FileSpreadsheet className="h-3.5 w-3.5" />
89+
{meta.file || "Sem arquivo"}
90+
</Badge>
91+
<Badge className="gap-1 text-xs">
92+
<BriefcaseBusiness className="h-3.5 w-3.5" />
93+
{meta.total} vagas
94+
</Badge>
95+
</div>
96+
</div>
97+
<Button variant={"outline"} onClick={() => setSeeKeywordsModal(true)}>Keywords</Button>
8998
</div>
90-
</div>
91-
</CardContent>
92-
</Card>
99+
</CardContent>
100+
</Card>
101+
{seeKeywordsModal && <KeywordsModal onClose={() => setSeeKeywordsModal(false)} />}
102+
</>
93103
);
94104
}

0 commit comments

Comments
 (0)