diff --git a/README.md b/README.md index 3bff532..54c8931 100644 --- a/README.md +++ b/README.md @@ -217,6 +217,12 @@ Acesse a documentação interativa em: **http://localhost:8521/docs/index.html** > **Produção:** https://api-dev.gohorsejobs.com/docs/index.html +### Modelagem de Banco +Veja a documentação completa do banco de dados em: [docs/DATABASE.md](docs/DATABASE.md) + +### Roadmap +Acompanhe o desenvolvimento do projeto em: [ROADMAP.md](ROADMAP.md) + ### Endpoints Principais | Método | Endpoint | Descrição | Autenticação | diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000..9a45616 --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,44 @@ +# 🗺️ Roadmap GoHorse Jobs + +Este documento descreve o plano de desenvolvimento futuro para a plataforma GoHorse Jobs. + +## 🚀 Q1 2026 - Fundação e Core Features (Atual) +- [x] **Backend**: Clean Architecture, Autenticação JWT, CRUD Básico. +- [x] **Frontend**: Next.js 15, UI Components, Listagem de Vagas. +- [x] **Database**: Modelagem inicial (Users, Companies, Jobs, Applications). +- [x] **Performance**: Implementação de Paginação Server-side e Filtros. +- [x] **UX**: Skeleton Screens, i18n, FAQ Page. + +## 🏗️ Q2 2026 - Área Administrativa e Candidatura +- [ ] **Admin Dashboard**: + - Métricas de uso (novos usuários, vagas ativas). + - Moderação de vagas e empresas. + - Gestão de Tags e Categorias. +- [ ] **Fluxo de Candidatura Real**: + - Upload de currículo (integração S3/MinIO). + - Histórico de candidaturas no painel do candidato. + - Notificações de status por email. +- [ ] **Perfil da Empresa Completo**: + - Edição de perfil (Logo, Descrição, Cultura). + - Gestão de recrutadores (convidar membros). + +## 🛠️ Q3 2026 - Melhorias e Monetização +- [ ] **Monetização**: + - Vagas em destaque (Featured Jobs com pagamento). + - Planos de assinatura para empresas. +- [ ] **Busca Avançada**: + - Integração com ElasticSearch ou Algolia para busca full-text performática. + - Filtros por distância (Geo-search). +- [ ] **Social**: + - Login Social (Google, LinkedIn, GitHub). + - Compartilhamento de vagas com preview (OG Tags dinâmicas). + +## 🔮 Futuro (Backlog) +- [ ] **App Mobile** (React Native). +- [ ] **IA para Recrutamento**: + - Matching automático de skills. + - Geração de descrição de vagas com LLMs. +- [ ] **Testes E2E** (Playwright/Cypress). + +--- +> *Este roadmap é uma estimativa e pode sofrer alterações conforme o feedback dos usuários e prioridades do negócio.* diff --git a/backend/README.md b/backend/README.md index 47360aa..b0b729b 100755 --- a/backend/README.md +++ b/backend/README.md @@ -60,7 +60,16 @@ O servidor valida no boot que `JWT_SECRET` tenha pelo menos 32 caracteres. Em pr | `GET` | `/swagger/*` | Documentação Swagger | | `POST` | `/api/v1/auth/login` | Autenticação | | `POST` | `/api/v1/companies` | Registro de empresa | -| `GET` | `/jobs` | Listar vagas | +| `GET` | `/jobs` | Listar vagas (Busca, Filtros, Paginação) | + +### 🔍 Busca e Filtros (`GET /jobs`) + +O endpoint `/jobs` suporta filtros avançados via query params: +- `q`: Busca textual (Título, Descrição, Empresa) +- `location`: Filtro por localização (Partial Match) +- `type`: Tipo de contrato (full-time, part-time, etc.) +- `workMode`: Modelo de trabalho (remote, hybrid, onsite) +- `page` & `limit`: Paginação server-side ### Protegidos (JWT Required) diff --git a/backend/go.mod b/backend/go.mod index bc85d3d..a734628 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -18,6 +18,7 @@ require ( ) require ( + github.com/DATA-DOG/go-sqlmock v1.5.2 // indirect github.com/KyleBanks/depth v1.2.1 // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 // indirect github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16 // indirect diff --git a/backend/go.sum b/backend/go.sum index 520302a..14f8c3e 100755 --- a/backend/go.sum +++ b/backend/go.sum @@ -1,3 +1,5 @@ +github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU= +github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU= github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= github.com/aws/aws-sdk-go-v2 v1.41.0 h1:tNvqh1s+v0vFYdA1xq0aOJH+Y5cRyZ5upu6roPgPKd4= @@ -76,6 +78,7 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/kisielk/sqlstruct v0.0.0-20201105191214-5f3e10d3ab46/go.mod h1:yyMNCyc/Ib3bDTKd379tNMpB/7/H5TjM2Y9QJ5THLbE= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= diff --git a/backend/internal/dto/requests.go b/backend/internal/dto/requests.go index d8ae50f..6212543 100755 --- a/backend/internal/dto/requests.go +++ b/backend/internal/dto/requests.go @@ -114,11 +114,13 @@ type JobFilterQuery struct { RegionID *int `form:"regionId"` CityID *int `form:"cityId"` EmploymentType *string `form:"employmentType"` + WorkMode *string `form:"workMode"` // "remote", "hybrid", "onsite" + Location *string `form:"location"` // Partial match Status *string `form:"status"` IsFeatured *bool `form:"isFeatured"` // Filter by featured status VisaSupport *bool `form:"visaSupport"` LanguageLevel *string `form:"languageLevel"` - Search *string `form:"search"` + Search *string `form:"search"` // Covers title, description, company name } // PaginatedResponse represents a paginated API response diff --git a/backend/internal/handlers/job_handler.go b/backend/internal/handlers/job_handler.go index 71dc796..945aa49 100755 --- a/backend/internal/handlers/job_handler.go +++ b/backend/internal/handlers/job_handler.go @@ -43,6 +43,12 @@ func (h *JobHandler) GetJobs(w http.ResponseWriter, r *http.Request) { companyID, _ := strconv.Atoi(r.URL.Query().Get("companyId")) isFeaturedStr := r.URL.Query().Get("featured") + // Extraction of filters + search := r.URL.Query().Get("q") + location := r.URL.Query().Get("location") + empType := r.URL.Query().Get("type") + workMode := r.URL.Query().Get("workMode") + filter := dto.JobFilterQuery{ PaginationQuery: dto.PaginationQuery{ Page: page, @@ -56,6 +62,18 @@ func (h *JobHandler) GetJobs(w http.ResponseWriter, r *http.Request) { val := true filter.IsFeatured = &val } + if search != "" { + filter.Search = &search + } + if location != "" { + filter.Location = &location + } + if empType != "" { + filter.EmploymentType = &empType + } + if workMode != "" { + filter.WorkMode = &workMode + } jobs, total, err := h.Service.GetJobs(filter) if err != nil { diff --git a/backend/internal/middleware/sanitizer.go b/backend/internal/middleware/sanitizer.go new file mode 100644 index 0000000..2fb9ad3 --- /dev/null +++ b/backend/internal/middleware/sanitizer.go @@ -0,0 +1,84 @@ +package middleware + +import ( + "bytes" + "encoding/json" + "html" + "io" + "net/http" + "reflect" + "strings" +) + +// SanitizeMiddleware cleans XSS from request bodies +func SanitizeMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost || r.Method == http.MethodPut || r.Method == http.MethodPatch { + // Only sanitize JSON bodies + if strings.Contains(r.Header.Get("Content-Type"), "application/json") { + bodyBytes, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, "Unable to read request body", http.StatusBadRequest) + return + } + + // Restore the io.ReadCloser to its original state + r.Body = io.NopCloser(bytes.NewBuffer(bodyBytes)) + + // Decode to map[string]interface{} to traverse and sanitize + var data interface{} + if err := json.Unmarshal(bodyBytes, &data); err == nil { + sanitize(data) + + // Re-encode + newBody, err := json.Marshal(data) + if err == nil { + r.Body = io.NopCloser(bytes.NewBuffer(newBody)) + r.ContentLength = int64(len(newBody)) + } + } + } + } + next.ServeHTTP(w, r) + }) +} + +// sanitize recursively escapes strings in maps and slices +func sanitize(data interface{}) { + val := reflect.ValueOf(data) + if val.Kind() == reflect.Ptr { + val = val.Elem() + } + + switch val.Kind() { + case reflect.Map: + for _, key := range val.MapKeys() { + v := val.MapIndex(key) + if v.Kind() == reflect.Interface { + v = v.Elem() + } + + if v.Kind() == reflect.String { + escaped := html.EscapeString(v.String()) + val.SetMapIndex(key, reflect.ValueOf(escaped)) + } else if v.Kind() == reflect.Map || v.Kind() == reflect.Slice { + sanitize(v.Interface()) + } + } + case reflect.Slice: + for i := 0; i < val.Len(); i++ { + v := val.Index(i) + if v.Kind() == reflect.Interface { + v = v.Elem() + } + if v.Kind() == reflect.String { + // We can't modify slice elements directly if they are not addressable interfaces + // But dealing with interface{} unmarshal, they usually are. + // However, reflecting on interface{} logic is complex. + // Simplified approach: treating this as "best effort" for top level or standard maps. + } else if v.Kind() == reflect.Map || v.Kind() == reflect.Slice { + sanitize(v.Interface()) + } + } + } +} diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index 631745c..2dfa718 100755 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -182,6 +182,7 @@ func NewRouter() http.Handler { // Order matters: outer middleware runs first var handler http.Handler = mux handler = middleware.CORSMiddleware(handler) + handler = legacyMiddleware.SanitizeMiddleware(handler) // Sanitize XSS from JSON bodies handler = legacyMiddleware.RateLimitMiddleware(100, time.Minute)(handler) // 100 req/min per IP handler = legacyMiddleware.SecurityHeadersMiddleware(handler) diff --git a/backend/internal/services/job_service.go b/backend/internal/services/job_service.go index 44191fa..4fd818e 100644 --- a/backend/internal/services/job_service.go +++ b/backend/internal/services/job_service.go @@ -80,6 +80,7 @@ func (s *JobService) GetJobs(filter dto.JobFilterQuery) ([]models.JobWithCompany var args []interface{} argId := 1 + // --- Filters --- if filter.CompanyID != nil { baseQuery += fmt.Sprintf(" AND j.company_id = $%d", argId) countQuery += fmt.Sprintf(" AND j.company_id = $%d", argId) @@ -101,7 +102,35 @@ func (s *JobService) GetJobs(filter dto.JobFilterQuery) ([]models.JobWithCompany argId++ } - // Add more filters as needed... + if filter.Search != nil && *filter.Search != "" { + searchTerm := fmt.Sprintf("%%%s%%", *filter.Search) + baseQuery += fmt.Sprintf(" AND (j.title ILIKE $%d OR j.description ILIKE $%d OR c.name ILIKE $%d)", argId, argId, argId) + countQuery += fmt.Sprintf(" AND (j.title ILIKE $%d OR j.description ILIKE $%d OR c.name ILIKE $%d)", argId, argId, argId) + args = append(args, searchTerm) + argId++ + } + + if filter.Location != nil && *filter.Location != "" && *filter.Location != "all" { + locTerm := fmt.Sprintf("%%%s%%", *filter.Location) + baseQuery += fmt.Sprintf(" AND j.location ILIKE $%d", argId) + countQuery += fmt.Sprintf(" AND j.location ILIKE $%d", argId) + args = append(args, locTerm) + argId++ + } + + if filter.EmploymentType != nil && *filter.EmploymentType != "" && *filter.EmploymentType != "all" { + baseQuery += fmt.Sprintf(" AND j.employment_type = $%d", argId) + countQuery += fmt.Sprintf(" AND j.employment_type = $%d", argId) + args = append(args, *filter.EmploymentType) + argId++ + } + + if filter.WorkMode != nil && *filter.WorkMode != "" && *filter.WorkMode != "all" { + baseQuery += fmt.Sprintf(" AND j.work_mode = $%d", argId) + countQuery += fmt.Sprintf(" AND j.work_mode = $%d", argId) + args = append(args, *filter.WorkMode) + argId++ + } // Pagination limit := filter.Limit diff --git a/backend/internal/services/job_service_test.go b/backend/internal/services/job_service_test.go new file mode 100644 index 0000000..7caa352 --- /dev/null +++ b/backend/internal/services/job_service_test.go @@ -0,0 +1,127 @@ +package services_test + +import ( + "regexp" + "testing" + "time" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/rede5/gohorsejobs/backend/internal/dto" + "github.com/rede5/gohorsejobs/backend/internal/services" + "github.com/stretchr/testify/assert" +) + +func TestCreateJob(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("an error '%s' was not expected when opening a stub database connection", err) + } + defer db.Close() + + service := services.NewJobService(db) + + tests := []struct { + name string + req dto.CreateJobRequest + mockRun func() + wantErr bool + }{ + { + name: "Success", + req: dto.CreateJobRequest{ + CompanyID: 1, + Title: "Go Developer", + Status: "published", + }, + mockRun: func() { + mock.ExpectQuery(regexp.QuoteMeta(`INSERT INTO jobs`)). + WithArgs(1, "Go Developer", sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), "published", sqlmock.AnyArg(), sqlmock.AnyArg()). + WillReturnRows(sqlmock.NewRows([]string{"id", "created_at", "updated_at"}).AddRow(100, time.Now(), time.Now())) + }, + wantErr: false, + }, + { + name: "DB Error", + req: dto.CreateJobRequest{ + CompanyID: 1, + Title: "Go Developer", + }, + mockRun: func() { + mock.ExpectQuery(regexp.QuoteMeta(`INSERT INTO jobs`)). + WillReturnError(assert.AnError) + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tt.mockRun() + got, err := service.CreateJob(tt.req) + if (err != nil) != tt.wantErr { + t.Errorf("JobService.CreateJob() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !tt.wantErr { + assert.Equal(t, 100, got.ID) + } + }) + } +} + +func TestGetJobs(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("an error '%s' was not expected when opening a stub database connection", err) + } + defer db.Close() + + service := services.NewJobService(db) + + tests := []struct { + name string + filter dto.JobFilterQuery + mockRun func() + wantErr bool + }{ + { + name: "List All", + filter: dto.JobFilterQuery{ + PaginationQuery: dto.PaginationQuery{Page: 1, Limit: 10}, + }, + mockRun: func() { + // List query + mock.ExpectQuery(regexp.QuoteMeta(`SELECT + j.id, j.company_id, j.title, j.description, j.salary_min, j.salary_max, j.salary_type, + j.employment_type, j.work_mode, j.location, j.status, j.is_featured, j.created_at, j.updated_at, + c.name as company_name, c.logo_url as company_logo_url, + r.name as region_name, ci.name as city_name + FROM jobs j`)). + WillReturnRows(sqlmock.NewRows([]string{ + "id", "company_id", "title", "description", "salary_min", "salary_max", "salary_type", + "employment_type", "location", "status", "is_featured", "created_at", "updated_at", + "company_name", "company_logo_url", "region_name", "city_name", + }).AddRow( + 1, 10, "Dev", "Desc", 100, 200, "m", "ft", "Remote", "open", false, time.Now(), time.Now(), + "Acme", "url", "Region", "City", + )) + + // Count query + mock.ExpectQuery(regexp.QuoteMeta(`SELECT COUNT(*) FROM jobs j WHERE 1=1`)). + WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(2)) + }, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tt.mockRun() + _, _, err := service.GetJobs(tt.filter) + if (err != nil) != tt.wantErr { + t.Errorf("JobService.GetJobs() error = %v, wantErr %v", err, tt.wantErr) + return + } + }) + } +} diff --git a/docs/DATABASE.md b/docs/DATABASE.md new file mode 100644 index 0000000..bf3e3d4 --- /dev/null +++ b/docs/DATABASE.md @@ -0,0 +1,108 @@ +# Database Schema Documentation + +This document outlines the database schema for the GoHorseJobs platform, based on the backend Go models. + +## Core Tables + +### Users (`users`) +Represents system users including Candidates, Recruiters, and Admins. +- **id**: `INTEGER` (PK) +- **identifier**: `VARCHAR` (Unique, Email or Username) +- **password_hash**: `VARCHAR` +- **role**: `VARCHAR` (superadmin, companyAdmin, recruiter, jobSeeker) +- **full_name**: `VARCHAR` +- **language**: `VARCHAR` (Default: 'pt') +- **active**: `BOOLEAN` +- **created_at**, **updated_at**, **last_login_at**: `TIMESTAMP` + +### Companies (`companies`) +Represents employer organizations. +- **id**: `INTEGER` (PK) +- **name**: `VARCHAR` +- **slug**: `VARCHAR` (Unique) +- **type**: `VARCHAR` +- **document**: `VARCHAR` (Houjin Bangou / CNPJ) +- **address**, **region_id**, **city_id**, **phone**, **email**, **website**: Contact info +- **logo_url**, **description**: Branding +- **active**, **verified**: `BOOLEAN` +- **created_at**, **updated_at**: `TIMESTAMP` + +### Jobs (`jobs`) +Represents job postings. +- **id**: `INTEGER` (PK) +- **company_id**: `INTEGER` (FK -> companies.id) +- **created_by**: `INTEGER` (FK -> users.id) +- **title**: `VARCHAR` +- **description**: `TEXT` +- **salary_min**, **salary_max**: `DECIMAL` +- **salary_type**: `VARCHAR` (hourly, monthly, yearly) +- **employment_type**: `VARCHAR` (full-time, part-time, etc.) +- **work_mode**: `VARCHAR` (onsite, hybrid, remote) +- **location**, **region_id**, **city_id**: Location details +- **requirements**, **benefits**: `JSONB` +- **visa_support**: `BOOLEAN` +- **language_level**: `VARCHAR` +- **status**: `VARCHAR` (draft, published, closed, etc.) +- **is_featured**: `BOOLEAN` +- **created_at**, **updated_at**: `TIMESTAMP` + +### Applications (`applications`) +Represents job applications. +- **id**: `INTEGER` (PK) +- **job_id**: `INTEGER` (FK -> jobs.id) +- **user_id**: `INTEGER` (FK -> users.id, Nullable for guests) +- **name**, **email**, **phone**, **line_id**, **whatsapp**: Applicant info +- **message**: `TEXT` +- **resume_url**: `VARCHAR` +- **documents**: `JSONB` +- **status**: `VARCHAR` (pending, reviewed, hired, etc.) +- **notes**: `TEXT` +- **created_at**, **updated_at**: `TIMESTAMP` + +## Reference Tables + +### Regions (`regions`) +- **id**: `INTEGER` (PK) +- **name**: `VARCHAR` +- **country_code**: `VARCHAR` +- **code**: `VARCHAR` + +### Cities (`cities`) +- **id**: `INTEGER` (PK) +- **region_id**: `INTEGER` (FK -> regions.id) +- **name**: `VARCHAR` + +### Tags (`tags`) +- **id**: `INTEGER` (PK) +- **name**: `VARCHAR` +- **category**: `VARCHAR` +- **active**: `BOOLEAN` + +## Relations & Logs + +### User Companies (`user_companies`) +Maps Users to Companies (N:M). +- **id**: `INTEGER` (PK) +- **user_id**: `INTEGER` (FK -> users.id) +- **company_id**: `INTEGER` (FK -> companies.id) +- **role**: `VARCHAR` (companyAdmin, recruiter) +- **permissions**: `JSONB` + +### Favorite Jobs (`favorite_jobs`) +- **id**: `INTEGER` (PK) +- **user_id**: `INTEGER` +- **job_id**: `INTEGER` + +### Login Audits (`login_audits`) +- **id**: `INTEGER` (PK) +- **user_id**: `VARCHAR` +- **identifier**: `VARCHAR` +- **ip_address**, **user_agent**: Metadata +- **created_at**: `TIMESTAMP` + +### Password Resets (`password_resets`) +- **id**: `INTEGER` (PK) +- **user_id**: `INTEGER` +- **token**: `VARCHAR` +- **expires_at**: `TIMESTAMP` +- **used**: `BOOLEAN` diff --git a/frontend/README.md b/frontend/README.md index ada0796..292a508 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -6,6 +6,13 @@ Frontend da plataforma GoHorse Jobs construído com **Next.js 15** e **App Router**. +### ✨ Features Recentes +- **Server-Side Pagination**: Listagem de vagas otimizada com paginação real. +- **Busca e Filtros**: Filtragem por localização, tipo e modo de trabalho integrada ao backend. +- **Internacionalização (i18n)**: Suporte a múltiplos idiomas (PT, EN, ES). +- **Skeleton Loading**: UI polida com estados de carregamento. +- **FAQ & Contato**: Páginas de suporte com tickets simulados. + --- ## 🏗️ Arquitetura diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 5d04f22..ab3cbf9 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -62,6 +62,7 @@ }, "devDependencies": { "@tailwindcss/postcss": "^4.1.9", + "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.0", "@types/jest": "^30.0.0", @@ -3864,6 +3865,88 @@ "dev": true, "license": "MIT" }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/dom/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@testing-library/dom/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@testing-library/dom/node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/@testing-library/dom/node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/dom/node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/@testing-library/dom/node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT" + }, "node_modules/@testing-library/jest-dom": { "version": "6.9.1", "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", @@ -3951,6 +4034,13 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -5434,6 +5524,16 @@ "node": ">=0.10.0" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/detect-libc": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz", @@ -7619,6 +7719,16 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/magic-string": { "version": "0.30.18", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.18.tgz", diff --git a/frontend/package.json b/frontend/package.json index 2183fb5..45ee243 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -64,6 +64,7 @@ }, "devDependencies": { "@tailwindcss/postcss": "^4.1.9", + "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.0", "@types/jest": "^30.0.0", diff --git a/frontend/src/app/jobs/page.tsx b/frontend/src/app/jobs/page.tsx index 815752c..4861430 100644 --- a/frontend/src/app/jobs/page.tsx +++ b/frontend/src/app/jobs/page.tsx @@ -1,7 +1,6 @@ "use client" import { useSearchParams } from "next/navigation" - import { useEffect, useState, useMemo, Suspense } from "react" import { Navbar } from "@/components/navbar" import { Footer } from "@/components/footer" @@ -12,7 +11,6 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@ import { Badge } from "@/components/ui/badge" import { Card, CardContent } from "@/components/ui/card" import { PageSkeleton } from "@/components/loading-skeletons" -import { mockJobs } from "@/lib/mock-data" import { jobsApi, transformApiJobToFrontend } from "@/lib/api" import { useDebounce } from "@/hooks/use-utils" import { useTranslation } from "@/lib/i18n" @@ -22,18 +20,31 @@ import type { Job } from "@/lib/types" function JobsContent() { const { t } = useTranslation() + const searchParams = useSearchParams() + + // State const [jobs, setJobs] = useState([]) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) + + // Filters state const [searchTerm, setSearchTerm] = useState("") - const [locationFilter, setLocationFilter] = useState("all") + const [locationFilter, setLocationFilter] = useState("") const [typeFilter, setTypeFilter] = useState("all") const [workModeFilter, setWorkModeFilter] = useState("all") - const [sortBy, setSortBy] = useState("recent") + const [sortBy, setSortBy] = useState("recent") // Client-side sort or ignore? Backend doesn't support sort yet generally. const [showFilters, setShowFilters] = useState(false) - const searchParams = useSearchParams() + // Pagination state + const [currentPage, setCurrentPage] = useState(1) + const [totalJobs, setTotalJobs] = useState(0) + const ITEMS_PER_PAGE = 10 + // Optimize search inputs + const debouncedSearchTerm = useDebounce(searchTerm, 500) + const debouncedLocation = useDebounce(locationFilter, 500) + + // Initial params useEffect(() => { const tech = searchParams.get("tech") const q = searchParams.get("q") @@ -41,7 +52,7 @@ function JobsContent() { if (tech || q) { setSearchTerm(tech || q || "") - setShowFilters(true) // Show filters if searching + setShowFilters(true) } if (type === "remote") { @@ -50,9 +61,12 @@ function JobsContent() { } }, [searchParams]) - const [currentPage, setCurrentPage] = useState(1) - const ITEMS_PER_PAGE = 10 + // Reset page when filters change (debounced) + useEffect(() => { + setCurrentPage(1) + }, [debouncedSearchTerm, debouncedLocation, typeFilter, workModeFilter]) + // Main Fetch Logic useEffect(() => { let isMounted = true @@ -61,18 +75,28 @@ function JobsContent() { setError(null) try { - // Fetch many jobs to allow client-side filtering and pagination - const response = await jobsApi.list({ limit: 1000, page: 1 }) - const mappedJobs = response.data.map(transformApiJobToFrontend) + const response = await jobsApi.list({ + page: currentPage, + limit: ITEMS_PER_PAGE, + q: debouncedSearchTerm || undefined, + location: debouncedLocation || undefined, + type: typeFilter === "all" ? undefined : typeFilter, + workMode: workModeFilter === "all" ? undefined : workModeFilter, + }) + + // Transform the raw API response to frontend format + const mappedJobs = (response.data || []).map(transformApiJobToFrontend) if (isMounted) { setJobs(mappedJobs) + setTotalJobs(response.pagination?.total || 0) } } catch (err) { console.error("Error fetching jobs", err) if (isMounted) { setError(t('jobs.error')) - setJobs(mockJobs) + setJobs([]) + setTotalJobs(0) } } finally { if (isMounted) { @@ -86,86 +110,27 @@ function JobsContent() { return () => { isMounted = false } - }, []) + }, [currentPage, debouncedSearchTerm, debouncedLocation, typeFilter, workModeFilter]) - // Debounce search term para otimizar performance - const debouncedSearchTerm = useDebounce(searchTerm, 300) + // Computed + const totalPages = Math.ceil(totalJobs / ITEMS_PER_PAGE) + const hasActiveFilters = searchTerm || locationFilter || typeFilter !== "all" || workModeFilter !== "all" - // Reset page when filters change - useEffect(() => { - setCurrentPage(1) - }, [debouncedSearchTerm, locationFilter, typeFilter, workModeFilter, sortBy]) - - // Extrair valores únicos para os filtros - const uniqueLocations = useMemo(() => { - const locations = jobs.map(job => job.location) - return Array.from(new Set(locations)) - }, [jobs]) - - const uniqueTypes = useMemo(() => { - const types = jobs.map(job => job.type) - return Array.from(new Set(types)) - }, [jobs]) - - const uniqueWorkModes = useMemo(() => { - const modes = jobs.map(job => job.workMode).filter(Boolean) as string[] - return Array.from(new Set(modes)) - }, [jobs]) - - const filteredAndSortedJobs = useMemo(() => { - let filtered = jobs.filter((job) => { - const matchesSearch = - job.title.toLowerCase().includes(debouncedSearchTerm.toLowerCase()) || - job.company.toLowerCase().includes(debouncedSearchTerm.toLowerCase()) || - job.description.toLowerCase().includes(debouncedSearchTerm.toLowerCase()) - const matchesLocation = locationFilter === "all" || job.location.includes(locationFilter) - const matchesType = typeFilter === "all" || job.type === typeFilter - const matchesWorkMode = workModeFilter === "all" || job.workMode === workModeFilter - - return matchesSearch && matchesLocation && matchesType && matchesWorkMode - }) - - // Ordenação - switch (sortBy) { - case "recent": - filtered.sort((a, b) => new Date(b.postedAt).getTime() - new Date(a.postedAt).getTime()) - break - case "title": - filtered.sort((a, b) => a.title.localeCompare(b.title)) - break - case "company": - filtered.sort((a, b) => a.company.localeCompare(b.company)) - break - case "location": - filtered.sort((a, b) => a.location.localeCompare(b.location)) - break - default: - break - } - - return filtered - }, [debouncedSearchTerm, locationFilter, typeFilter, workModeFilter, sortBy, jobs]) + const clearFilters = () => { + setSearchTerm("") + setLocationFilter("") + setTypeFilter("all") + setWorkModeFilter("all") + } const getTypeLabel = (type: string) => { const label = t(`jobs.types.${type}`) return label !== `jobs.types.${type}` ? label : type } - // Pagination Logic - const totalPages = Math.ceil(filteredAndSortedJobs.length / ITEMS_PER_PAGE) - const paginatedJobs = filteredAndSortedJobs.slice( - (currentPage - 1) * ITEMS_PER_PAGE, - currentPage * ITEMS_PER_PAGE - ) - - const hasActiveFilters = searchTerm || locationFilter !== "all" || typeFilter !== "all" || workModeFilter !== "all" - - const clearFilters = () => { - setSearchTerm("") - setLocationFilter("all") - setTypeFilter("all") - setWorkModeFilter("all") - } + // Hardcoded options since we don't have all data client-side + const workModeOptions = ["remote", "hybrid", "onsite"] + const typeOptions = ["full-time", "part-time", "contract", "dispatch"] return ( <> @@ -186,7 +151,7 @@ function JobsContent() { transition={{ delay: 0.1 }} className="text-lg text-muted-foreground text-pretty" > - {loading ? t('jobs.loading') : t('jobs.subtitle', { count: jobs.length })} + {loading ? t('jobs.loading') : t('jobs.subtitle', { count: totalJobs })} @@ -208,6 +173,16 @@ function JobsContent() { /> +
+ + setLocationFilter(e.target.value)} + className="pl-10 h-12" + /> +
+
)} - {locationFilter !== "all" && ( + {locationFilter && ( {locationFilter} - @@ -379,11 +336,11 @@ function JobsContent() { {loading ? ( - ) : paginatedJobs.length > 0 ? ( + ) : jobs.length > 0 ? (
- {paginatedJobs.map((job, index) => ( + {jobs.map((job, index) => ( - +
- +