diff --git a/backend/internal/api/handlers/core_handlers.go b/backend/internal/api/handlers/core_handlers.go index 59cf203..8bf28e9 100644 --- a/backend/internal/api/handlers/core_handlers.go +++ b/backend/internal/api/handlers/core_handlers.go @@ -6,6 +6,7 @@ import ( "net/http" "strconv" "strings" + "time" "github.com/rede5/gohorsejobs/backend/internal/api/middleware" "github.com/rede5/gohorsejobs/backend/internal/core/dto" @@ -88,6 +89,18 @@ func (h *CoreHandlers) Login(w http.ResponseWriter, r *http.Request) { }) } + // Set HttpOnly Cookie + http.SetCookie(w, &http.Cookie{ + Name: "jwt", + Value: resp.Token, + Path: "/", + // Domain: "localhost", // Or separate based on env + Expires: time.Now().Add(24 * time.Hour), + HttpOnly: true, + Secure: false, // Set to true in production with HTTPS + SameSite: http.SameSiteLaxMode, + }) + w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(resp) } @@ -789,3 +802,64 @@ func (h *CoreHandlers) UploadMyAvatar(w http.ResponseWriter, r *http.Request) { "message": "Avatar upload mocked (S3 service pending injection)", }) } + +// Me returns the current user profile including company info. +// @Summary Get My Profile +// @Description Returns the profile of the authenticated user. +// @Tags Users +// @Accept json +// @Produce json +// @Security BearerAuth +// @Success 200 {object} dto.User +// @Failure 401 {string} string "Unauthorized" +// @Failure 500 {string} string "Internal Server Error" +// @Router /api/v1/users/me [get] +// Me returns the current user profile including company info. +// @Summary Get My Profile +// @Description Returns the profile of the authenticated user. +// @Tags Users +// @Accept json +// @Produce json +// @Security BearerAuth +// @Success 200 {object} dto.User +// @Failure 401 {string} string "Unauthorized" +// @Failure 500 {string} string "Internal Server Error" +// @Router /api/v1/users/me [get] +func (h *CoreHandlers) Me(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + userIDVal := ctx.Value(middleware.ContextUserID) + if userIDVal == nil { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + var userID int + switch v := userIDVal.(type) { + case int: + userID = v + case string: + var err error + userID, err = strconv.Atoi(v) + if err != nil { + http.Error(w, "Invalid User ID in context", http.StatusInternalServerError) + return + } + case float64: + userID = int(v) + } + + user, err := h.adminService.GetUser(ctx, userID) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + company, _ := h.adminService.GetCompanyByUserID(ctx, userID) + if company != nil { + id := company.ID + user.CompanyID = &id + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(user) +} diff --git a/backend/internal/api/handlers/core_handlers_test.go b/backend/internal/api/handlers/core_handlers_test.go index 31b4062..a02e918 100644 --- a/backend/internal/api/handlers/core_handlers_test.go +++ b/backend/internal/api/handlers/core_handlers_test.go @@ -2,12 +2,14 @@ package handlers_test import ( "bytes" + "context" "encoding/json" "net/http" "net/http/httptest" "testing" "github.com/rede5/gohorsejobs/backend/internal/api/handlers" + "github.com/rede5/gohorsejobs/backend/internal/core/domain/entity" "github.com/rede5/gohorsejobs/backend/internal/core/dto" auth "github.com/rede5/gohorsejobs/backend/internal/core/usecases/auth" tenant "github.com/rede5/gohorsejobs/backend/internal/core/usecases/tenant" @@ -17,32 +19,35 @@ import ( // --- Mock Implementations --- type mockUserRepo struct { - saveFunc func(user interface{}) (interface{}, error) - findByEmailFunc func(email string) (interface{}, error) + saveFunc func(user *entity.User) (*entity.User, error) + findByEmailFunc func(email string) (*entity.User, error) } -func (m *mockUserRepo) Save(ctx interface{}, user interface{}) (interface{}, error) { +func (m *mockUserRepo) Save(ctx context.Context, user *entity.User) (*entity.User, error) { if m.saveFunc != nil { return m.saveFunc(user) } return user, nil } -func (m *mockUserRepo) FindByEmail(ctx interface{}, email string) (interface{}, error) { +func (m *mockUserRepo) FindByEmail(ctx context.Context, email string) (*entity.User, error) { if m.findByEmailFunc != nil { return m.findByEmailFunc(email) } return nil, nil } -func (m *mockUserRepo) FindByID(ctx interface{}, id string) (interface{}, error) { return nil, nil } -func (m *mockUserRepo) FindAllByTenant(ctx interface{}, tenantID string, l, o int) ([]interface{}, int, error) { - return nil, 0, nil -} -func (m *mockUserRepo) Update(ctx interface{}, user interface{}) (interface{}, error) { +func (m *mockUserRepo) FindByID(ctx context.Context, id string) (*entity.User, error) { return nil, nil } -func (m *mockUserRepo) Delete(ctx interface{}, id string) error { return nil } + +func (m *mockUserRepo) FindAllByTenant(ctx context.Context, tenantID string, l, o int) ([]*entity.User, int, error) { + return nil, 0, nil +} +func (m *mockUserRepo) Update(ctx context.Context, user *entity.User) (*entity.User, error) { + return nil, nil +} +func (m *mockUserRepo) Delete(ctx context.Context, id string) error { return nil } type mockAuthService struct{} @@ -60,20 +65,12 @@ func (m *mockAuthService) ValidateToken(token string) (map[string]interface{}, e // --- Test Cases --- func TestRegisterCandidateHandler_Success(t *testing.T) { - // This is a simplified integration test structure - // In production, you'd wire up the full dependency injection - t.Skip("Integration test requires full DI setup - use unit tests in usecases/auth instead") + t.Skip("Integration test requires full DI setup") } func TestRegisterCandidateHandler_InvalidPayload(t *testing.T) { - // Create a minimal handler for testing payload validation - coreHandlers := createTestCoreHandlers(t) - if coreHandlers == nil { - t.Skip("Cannot create test handlers - skipping") - return - } + coreHandlers := createTestCoreHandlers(t, nil) - // Test with invalid JSON body := bytes.NewBufferString("{invalid json}") req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/register", body) req.Header.Set("Content-Type", "application/json") @@ -87,11 +84,7 @@ func TestRegisterCandidateHandler_InvalidPayload(t *testing.T) { } func TestRegisterCandidateHandler_MissingFields(t *testing.T) { - coreHandlers := createTestCoreHandlers(t) - if coreHandlers == nil { - t.Skip("Cannot create test handlers - skipping") - return - } + coreHandlers := createTestCoreHandlers(t, nil) testCases := []struct { name string @@ -137,11 +130,7 @@ func TestRegisterCandidateHandler_MissingFields(t *testing.T) { } func TestLoginHandler_InvalidPayload(t *testing.T) { - coreHandlers := createTestCoreHandlers(t) - if coreHandlers == nil { - t.Skip("Cannot create test handlers - skipping") - return - } + coreHandlers := createTestCoreHandlers(t, nil) body := bytes.NewBufferString("{invalid}") req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", body) @@ -155,15 +144,68 @@ func TestLoginHandler_InvalidPayload(t *testing.T) { } } -// createTestCoreHandlers creates handlers with nil usecases for basic validation tests -// For full integration tests, wire up real mock implementations -func createTestCoreHandlers(t *testing.T) *handlers.CoreHandlers { - t.Helper() +func TestLoginHandler_Success(t *testing.T) { + // Mocks + mockRepo := &mockUserRepo{ + findByEmailFunc: func(email string) (*entity.User, error) { + if email == "john@example.com" { + // Return entity.User + u := entity.NewUser("u1", "t1", "John", "john@example.com") + u.PasswordHash = "hashed_123456" + // Add Role if needed (mocked) + // u.Roles = ... + return u, nil + } + return nil, nil // Not found + }, + } + mockAuth := &mockAuthService{} - // Return nil - these tests need proper DI which we skip for now - // The real tests are in usecases/auth package + // Real UseCase with Mocks + loginUC := auth.NewLoginUseCase(mockRepo, mockAuth) + + coreHandlers := createTestCoreHandlers(t, loginUC) + + // Request + payload := dto.LoginRequest{Email: "john@example.com", Password: "123456"} + body, _ := json.Marshal(payload) + req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + + coreHandlers.Login(rec, req) + + // Assert Response Code + if rec.Code != http.StatusOK { + t.Errorf("Expected status %d, got %d. Body: %s", http.StatusOK, rec.Code, rec.Body.String()) + } + + // Assert Cookie + cookies := rec.Result().Cookies() + var jwtCookie *http.Cookie + for _, c := range cookies { + if c.Name == "jwt" { + jwtCookie = c + break + } + } + + if jwtCookie == nil { + t.Fatal("Expected jwt cookie not found") + } + if !jwtCookie.HttpOnly { + t.Error("Cookie should be HttpOnly") + } + if jwtCookie.Value != "mock_token" { + t.Errorf("Expected cookie value 'mock_token', got '%s'", jwtCookie.Value) + } +} + +// createTestCoreHandlers creates handlers with mocks +func createTestCoreHandlers(t *testing.T, loginUC *auth.LoginUseCase) *handlers.CoreHandlers { + t.Helper() return handlers.NewCoreHandlers( - (*auth.LoginUseCase)(nil), + loginUC, (*auth.RegisterCandidateUseCase)(nil), (*tenant.CreateCompanyUseCase)(nil), (*user.CreateUserUseCase)(nil), @@ -171,8 +213,9 @@ func createTestCoreHandlers(t *testing.T) *handlers.CoreHandlers { (*user.DeleteUserUseCase)(nil), (*user.UpdateUserUseCase)(nil), (*tenant.ListCompaniesUseCase)(nil), - nil, // auditService - nil, // notificationService - nil, // ticketService + nil, + nil, + nil, + nil, ) } diff --git a/backend/internal/api/middleware/auth_middleware.go b/backend/internal/api/middleware/auth_middleware.go index 180d504..f3b42d1 100644 --- a/backend/internal/api/middleware/auth_middleware.go +++ b/backend/internal/api/middleware/auth_middleware.go @@ -28,18 +28,27 @@ func NewMiddleware(authService ports.AuthService) *Middleware { func (m *Middleware) HeaderAuthGuard(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { authHeader := r.Header.Get("Authorization") - if authHeader == "" { - http.Error(w, "Missing Authorization Header", http.StatusUnauthorized) - return + var token string + + if authHeader != "" { + parts := strings.Split(authHeader, " ") + if len(parts) == 2 && parts[0] == "Bearer" { + token = parts[1] + } } - parts := strings.Split(authHeader, " ") - if len(parts) != 2 || parts[0] != "Bearer" { - http.Error(w, "Invalid Header Format", http.StatusUnauthorized) - return + // Fallback to Cookie + if token == "" { + cookie, err := r.Cookie("jwt") + if err == nil { + token = cookie.Value + } } - token := parts[1] + if token == "" { + http.Error(w, "Missing Authorization Header or Cookie", http.StatusUnauthorized) + return + } claims, err := m.authService.ValidateToken(token) if err != nil { http.Error(w, "Invalid Token: "+err.Error(), http.StatusUnauthorized) @@ -59,21 +68,27 @@ func (m *Middleware) HeaderAuthGuard(next http.Handler) http.Handler { func (m *Middleware) OptionalHeaderAuthGuard(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { authHeader := r.Header.Get("Authorization") - if authHeader == "" { + var token string + + if authHeader != "" { + parts := strings.Split(authHeader, " ") + if len(parts) == 2 && parts[0] == "Bearer" { + token = parts[1] + } + } + + if token == "" { + cookie, err := r.Cookie("jwt") + if err == nil { + token = cookie.Value + } + } + + if token == "" { // Proceed without context next.ServeHTTP(w, r) return } - - parts := strings.Split(authHeader, " ") - if len(parts) != 2 || parts[0] != "Bearer" { - // If header exists but invalid, we return error to avoid confusion (or ignore?) - // Let's return error to be strict if they tried to authenticate. - http.Error(w, "Invalid Header Format", http.StatusUnauthorized) - return - } - - token := parts[1] claims, err := m.authService.ValidateToken(token) if err != nil { http.Error(w, "Invalid Token: "+err.Error(), http.StatusUnauthorized) diff --git a/backend/internal/dto/auth.go b/backend/internal/dto/auth.go index c013a00..9823156 100755 --- a/backend/internal/dto/auth.go +++ b/backend/internal/dto/auth.go @@ -1,5 +1,9 @@ package dto +import ( + "time" +) + // LoginRequest represents the login request payload type LoginRequest struct { Identifier string `json:"identifier" validate:"required,min=3"` @@ -42,3 +46,13 @@ type RegisterRequest struct { Language string `json:"language" validate:"required,oneof=pt en es ja"` Role string `json:"role" validate:"required,oneof=jobSeeker recruiter companyAdmin"` } + +// User represents a generic user profile +type User struct { + ID int `json:"id"` + Name string `json:"name"` + Email string `json:"email"` + Role string `json:"role"` + CreatedAt time.Time `json:"createdAt"` + CompanyID *string `json:"companyId,omitempty"` +} diff --git a/backend/internal/handlers/application_handler.go b/backend/internal/handlers/application_handler.go index 0af7b77..5d95506 100644 --- a/backend/internal/handlers/application_handler.go +++ b/backend/internal/handlers/application_handler.go @@ -5,6 +5,7 @@ import ( "net/http" "github.com/rede5/gohorsejobs/backend/internal/dto" + "github.com/rede5/gohorsejobs/backend/internal/models" "github.com/rede5/gohorsejobs/backend/internal/services" ) @@ -57,14 +58,24 @@ func (h *ApplicationHandler) CreateApplication(w http.ResponseWriter, r *http.Re // @Failure 500 {string} string "Internal Server Error" // @Router /api/v1/applications [get] func (h *ApplicationHandler) GetApplications(w http.ResponseWriter, r *http.Request) { - // For now, simple get by Job ID query param + // Check for filters jobID := r.URL.Query().Get("jobId") - if jobID == "" { - http.Error(w, "jobId is required", http.StatusBadRequest) + companyID := r.URL.Query().Get("companyId") + + if jobID == "" && companyID == "" { + http.Error(w, "jobId or companyId is required", http.StatusBadRequest) return } - apps, err := h.Service.GetApplications(jobID) + var apps []models.Application + var err error + + if companyID != "" { + apps, err = h.Service.GetApplicationsByCompany(companyID) + } else { + apps, err = h.Service.GetApplications(jobID) + } + if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return diff --git a/backend/internal/infrastructure/auth/jwt_service.go b/backend/internal/infrastructure/auth/jwt_service.go index d11fd9f..ac156a8 100644 --- a/backend/internal/infrastructure/auth/jwt_service.go +++ b/backend/internal/infrastructure/auth/jwt_service.go @@ -2,6 +2,7 @@ package auth import ( "errors" + "os" "time" "github.com/golang-jwt/jwt/v5" @@ -21,12 +22,21 @@ func NewJWTService(secret string, issuer string) *JWTService { } func (s *JWTService) HashPassword(password string) (string, error) { - bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + pepper := os.Getenv("PASSWORD_PEPPER") + if pepper == "" { + // Log warning or fail? Ideally fail safe, but for now fallback or log. + // For transparency, we will proceed but it's risky if configured to usage. + } + // Combine password and pepper + passwordWithPepper := password + pepper + bytes, err := bcrypt.GenerateFromPassword([]byte(passwordWithPepper), bcrypt.DefaultCost) return string(bytes), err } func (s *JWTService) VerifyPassword(hash, password string) bool { - err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) + pepper := os.Getenv("PASSWORD_PEPPER") + passwordWithPepper := password + pepper + err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(passwordWithPepper)) return err == nil } diff --git a/backend/internal/infrastructure/auth/jwt_service_test.go b/backend/internal/infrastructure/auth/jwt_service_test.go new file mode 100644 index 0000000..55e7ddb --- /dev/null +++ b/backend/internal/infrastructure/auth/jwt_service_test.go @@ -0,0 +1,75 @@ +package auth_test + +import ( + "os" + "testing" + + "github.com/rede5/gohorsejobs/backend/internal/infrastructure/auth" + "github.com/stretchr/testify/assert" +) + +func TestJWTService_HashAndVerifyPassword(t *testing.T) { + // Setup + os.Setenv("PASSWORD_PEPPER", "test-pepper") + defer os.Unsetenv("PASSWORD_PEPPER") + + service := auth.NewJWTService("secret", "issuer") + + t.Run("Should hash and verify password correctly", func(t *testing.T) { + password := "mysecurepassword" + hash, err := service.HashPassword(password) + assert.NoError(t, err) + assert.NotEmpty(t, hash) + + valid := service.VerifyPassword(hash, password) + assert.True(t, valid) + }) + + t.Run("Should fail verification with wrong password", func(t *testing.T) { + password := "password" + hash, _ := service.HashPassword(password) + + valid := service.VerifyPassword(hash, "wrong-password") + assert.False(t, valid) + }) + + t.Run("Should fail verification with wrong pepper", func(t *testing.T) { + password := "password" + hash, _ := service.HashPassword(password) + + // Change pepper + os.Setenv("PASSWORD_PEPPER", "wrong-pepper") + valid := service.VerifyPassword(hash, password) + assert.False(t, valid) + + // Reset pepper + os.Setenv("PASSWORD_PEPPER", "test-pepper") + }) +} + +func TestJWTService_TokenOperations(t *testing.T) { + service := auth.NewJWTService("secret", "issuer") + + t.Run("Should generate and validate token", func(t *testing.T) { + userID := "user-123" + tenantID := "tenant-456" + roles := []string{"admin"} + + token, err := service.GenerateToken(userID, tenantID, roles) + assert.NoError(t, err) + assert.NotEmpty(t, token) + + claims, err := service.ValidateToken(token) + assert.NoError(t, err) + assert.Equal(t, userID, claims["sub"]) + assert.Equal(t, tenantID, claims["tenant"]) + // JSON numbers are float64, so careful with types if we check deep structure, + // but roles might come back as []interface{} + }) + + t.Run("Should fail invalid token", func(t *testing.T) { + claims, err := service.ValidateToken("invalid-token") + assert.Error(t, err) + assert.Nil(t, claims) + }) +} diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index 95da282..b32eb80 100755 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -156,6 +156,11 @@ func NewRouter() http.Handler { // /api/v1/admin/audit/logins -> /api/v1/audit/logins mux.Handle("GET /api/v1/audit/logins", authMiddleware.HeaderAuthGuard(adminOnly(http.HandlerFunc(adminHandlers.ListLoginAudits)))) + // Public /api/v1/users/me (Authenticated) + mux.Handle("GET /api/v1/users/me", authMiddleware.HeaderAuthGuard(http.HandlerFunc(coreHandlers.Me))) + // Admin /api/v1/users (List) + mux.Handle("GET /api/v1/users", authMiddleware.HeaderAuthGuard(adminOnly(http.HandlerFunc(coreHandlers.ListUsers)))) + // /api/v1/admin/companies -> Handled by coreHandlers.ListCompanies (Smart Branching) // Needs to be wired with Optional Auth to support both Public and Admin. // I will create OptionalHeaderAuthGuard in middleware next. diff --git a/backend/internal/services/admin_service.go b/backend/internal/services/admin_service.go index d711964..645ed2e 100644 --- a/backend/internal/services/admin_service.go +++ b/backend/internal/services/admin_service.go @@ -502,3 +502,53 @@ func (s *AdminService) getTagByID(ctx context.Context, id int) (*models.Tag, err } return &t, nil } + +// GetUser fetches a user by ID +func (s *AdminService) GetUser(ctx context.Context, id int) (*dto.User, error) { + query := ` + SELECT id, name, email, role, created_at + FROM users WHERE id = $1 + ` + var u dto.User + var roleStr string + if err := s.DB.QueryRowContext(ctx, query, id).Scan(&u.ID, &u.Name, &u.Email, &roleStr, &u.CreatedAt); err != nil { + return nil, err + } + u.Role = roleStr + return &u, nil +} + +// GetCompanyByUserID fetches the company associated with a user +func (s *AdminService) GetCompanyByUserID(ctx context.Context, userID int) (*models.Company, error) { + // First, try to find company where this user is admin + // Assuming users table has company_id or companies table has admin_email + // Let's check if 'users' has company_id column via error or assume architecture. + // Since CreateCompany creates a user, it likely links them. + // I will try to find a company by created_by = user_id IF that column exists? + // Or query based on some relation. + // Let's try finding company by admin_email matching user email. + + // Fetch user email first + user, err := s.GetUser(ctx, userID) + if err != nil { + return nil, err + } + + query := `SELECT id, name, slug, active, verified FROM companies WHERE email = $1` + var c models.Company + if err := s.DB.QueryRowContext(ctx, query, user.Email).Scan(&c.ID, &c.Name, &c.Slug, &c.Active, &c.Verified); err != nil { + // Try another way? Join companies c JOIN users u ON u.company_id = c.id + // If users table has company_id column... + // Let's try that as fallback. + query2 := ` + SELECT c.id, c.name, c.slug, c.active, c.verified + FROM companies c + JOIN users u ON u.company_id = c.id + WHERE u.id = $1 + ` + if err2 := s.DB.QueryRowContext(ctx, query2, userID).Scan(&c.ID, &c.Name, &c.Slug, &c.Active, &c.Verified); err2 != nil { + return nil, fmt.Errorf("company not found for user %d", userID) + } + } + return &c, nil +} diff --git a/backend/internal/services/application_service.go b/backend/internal/services/application_service.go index 10b7b26..28bafd0 100644 --- a/backend/internal/services/application_service.go +++ b/backend/internal/services/application_service.go @@ -112,3 +112,32 @@ func (s *ApplicationService) UpdateApplicationStatus(id string, req dto.UpdateAp return s.GetApplicationByID(id) } + +func (s *ApplicationService) GetApplicationsByCompany(companyID string) ([]models.Application, error) { + query := ` + SELECT a.id, a.job_id, a.user_id, a.name, a.phone, a.line_id, a.whatsapp, a.email, + a.message, a.resume_url, a.status, a.created_at, a.updated_at + FROM applications a + JOIN jobs j ON a.job_id = j.id + WHERE j.company_id = $1 + ORDER BY a.created_at DESC + ` + rows, err := s.DB.Query(query, companyID) + if err != nil { + return nil, err + } + defer rows.Close() + + var apps []models.Application + for rows.Next() { + var a models.Application + if err := rows.Scan( + &a.ID, &a.JobID, &a.UserID, &a.Name, &a.Phone, &a.LineID, &a.WhatsApp, &a.Email, + &a.Message, &a.ResumeURL, &a.Status, &a.CreatedAt, &a.UpdatedAt, + ); err != nil { + return nil, err + } + apps = append(apps, a) + } + return apps, nil +} diff --git a/frontend/src/app/dashboard/page.tsx b/frontend/src/app/dashboard/page.tsx index 7690bbb..72392ad 100644 --- a/frontend/src/app/dashboard/page.tsx +++ b/frontend/src/app/dashboard/page.tsx @@ -34,7 +34,7 @@ export default function DashboardPage() { } if (user.role === "company" || user.roles?.includes("companyAdmin")) { - return + return } if (user.role === "candidate" || user.roles?.includes("jobSeeker")) { diff --git a/frontend/src/app/dashboard/profile/page.tsx b/frontend/src/app/dashboard/profile/page.tsx index 8fff4a1..019c698 100644 --- a/frontend/src/app/dashboard/profile/page.tsx +++ b/frontend/src/app/dashboard/profile/page.tsx @@ -33,7 +33,7 @@ export default function ProfilePage() { setUser(userData) setFormData({ fullName: userData.fullName || "", - email: userData.identifier || "", + email: userData.email || "", phone: userData.phone || "", bio: userData.bio || "" }) diff --git a/frontend/src/components/dashboard-contents/company-dashboard.tsx b/frontend/src/components/dashboard-contents/company-dashboard.tsx index c8fc35f..85e8a52 100644 --- a/frontend/src/components/dashboard-contents/company-dashboard.tsx +++ b/frontend/src/components/dashboard-contents/company-dashboard.tsx @@ -1,5 +1,4 @@ -"use client" - +import { useEffect, useState } from "react" import { Card, CardContent, @@ -20,89 +19,83 @@ import { Calendar, MapPin, DollarSign, - MessageSquare, - BarChart3, } from "lucide-react" import Link from "next/link" +import { jobsApi, applicationsApi, ApiJob } from "@/lib/api" +import { User } from "@/lib/auth" +import { formatDistanceToNow } from "date-fns" +import { ptBR } from "date-fns/locale" -export function CompanyDashboardContent() { - const companyStats = { - activeJobs: 12, - totalApplications: 234, - totalViews: 1542, - thisMonth: 89, - } +interface CompanyDashboardContentProps { + user: User +} - const recentJobs = [ - { - id: "1", - title: "Senior Full Stack Developer", - type: "Full Time", - location: "São Paulo, SP", - salary: "R$ 12,000 - R$ 18,000", - applications: 45, - views: 320, - postedAt: "2 days ago", - status: "active", - }, - { - id: "2", - title: "Designer UX/UI", - type: "Remote", - location: "Remote", - salary: "R$ 8,000 - R$ 12,000", - applications: 32, - views: 256, - postedAt: "5 days ago", - status: "active", - }, - { - id: "3", - title: "Product Manager", - type: "Full Time", - location: "São Paulo, SP", - salary: "R$ 15,000 - R$ 20,000", - applications: 28, - views: 189, - postedAt: "1 week ago", - status: "active", - }, - ] +export function CompanyDashboardContent({ user }: CompanyDashboardContentProps) { + const [stats, setStats] = useState({ + activeJobs: 0, + totalApplications: 0, + totalViews: 0, + thisMonth: 0, + }) + const [recentJobs, setRecentJobs] = useState([]) + const [recentApplications, setRecentApplications] = useState([]) + const [loading, setLoading] = useState(true) - const recentApplications = [ - { - id: "1", - candidateName: "Ana Silva", - candidateAvatar: "", - jobTitle: "Senior Full Stack Developer", - appliedAt: "2 hours ago", - status: "pending", - }, - { - id: "2", - candidateName: "Carlos Santos", - candidateAvatar: "", - jobTitle: "Designer UX/UI", - appliedAt: "5 hours ago", - status: "pending", - }, - { - id: "3", - candidateName: "Maria Oliveira", - candidateAvatar: "", - jobTitle: "Product Manager", - appliedAt: "1 day ago", - status: "reviewing", - }, - ] + useEffect(() => { + const fetchData = async () => { + try { + // Use user.companyId if available, or fetch current user profile again to be sure? + // For now assuming user.id is the link or user has companyId property if updated + // The backend Me handler now returns companyId. + // We should rely on what's passed. + // If user doesn't have companyId, we might fail to filter. + // But let's try passing companyId from user if it exists (we added it to DTO but generic User interface might need update). + // Casting user to any to access companyId safely + const companyId = (user as any).companyId?.toString(); - const statusColors = { + const [jobsRes, appsRes] = await Promise.all([ + jobsApi.list({ limit: 5, companyId }), + applicationsApi.list({ companyId }) + ]) + + const jobs = jobsRes.data || [] + const applications = appsRes || [] + + setRecentJobs(jobs.slice(0, 5)) + setRecentApplications(applications.slice(0, 5)) + + // Calculate Stats + const activeJobs = jobs.length // This is just page 1. Ideally we get total from pagination + // But pagination total is in jobsRes.pagination.total + const totalActive = jobsRes.pagination?.total || 0 + + setStats({ + activeJobs: totalActive, + totalApplications: applications.length, + totalViews: 0, // Not available yet + thisMonth: applications.filter(a => new Date(a.created_at) > new Date(new Date().setDate(1))).length + }) + } catch (error) { + console.error("Error fetching dashboard data:", error) + } finally { + setLoading(false) + } + } + + fetchData() + }, [user]) + + const statusColors: any = { pending: "bg-yellow-500", reviewing: "bg-blue-500", accepted: "bg-green-500", rejected: "bg-red-500", } + if (loading) { + return
Carregando dashboard...
+ } + return (
{/* Header */} @@ -112,13 +105,13 @@ export function CompanyDashboardContent() { Dashboard

- Welcome back, TechCorp! 👋 + Olá, {user.name}! 👋

@@ -128,16 +121,16 @@ export function CompanyDashboardContent() { - Active jobs + Vagas Ativas
- {companyStats.activeJobs} + {stats.activeJobs}

- Live right now + Publicadas

@@ -145,16 +138,16 @@ export function CompanyDashboardContent() { - Applications + Candidaturas
- {companyStats.totalApplications} + {stats.totalApplications}

- +{companyStats.thisMonth} this month + +{stats.thisMonth} este mês

@@ -162,16 +155,16 @@ export function CompanyDashboardContent() { - Views + Visualizações
- {companyStats.totalViews} + -

- On your postings + Em breve

@@ -179,14 +172,14 @@ export function CompanyDashboardContent() { - Conversion rate + Conversão -
15.2%
-

- +2.5% vs last month +

-
+

+ Em breve

@@ -199,20 +192,22 @@ export function CompanyDashboardContent() {
- Recent jobs + Vagas Recentes - Your latest job postings + Suas últimas vagas publicadas
- +
- {recentJobs.map((job) => ( + {recentJobs.length === 0 ? ( +

Nenhuma vaga encontrada.

+ ) : recentJobs.map((job) => (
- {job.salary} + {job.salaryMin ? `R$ ${job.salaryMin}` : 'A combinar'}
- {job.applications} applications - - - - {job.views} views + {/* Mocking app count if not available */} + {job.applicationCount || 0} applications - {job.postedAt} + {formatDistanceToNow(new Date(job.createdAt), { addSuffix: true, locale: ptBR })}
@@ -275,50 +267,51 @@ export function CompanyDashboardContent() {
- Applications - New applications + Candidaturas + Candidatos recentes
- {recentApplications.map((application) => ( + {recentApplications.length === 0 ? ( +

Nenhuma candidatura recente.

+ ) : recentApplications.map((application) => (
- - {application.candidateName + {application.name .split(" ") - .map((n) => n[0]) + .map((n: string) => n[0]) + .slice(0, 2) .join("") - .toUpperCase() - .slice(0, 2)} + .toUpperCase()}

- {application.candidateName} + {application.name}

- {application.jobTitle} + {application.jobTitle || "Vaga desconhecida"}

- {application.appliedAt} + {formatDistanceToNow(new Date(application.created_at), { addSuffix: true, locale: ptBR })}

diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 45d52bc..0bbb940 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -26,6 +26,7 @@ async function apiRequest(endpoint: string, options: RequestInit = {}): Promi const response = await fetch(`${API_BASE_URL}${endpoint}`, { ...options, headers, + credentials: "include", // Enable cookie sharing }); if (!response.ok) { @@ -45,11 +46,14 @@ async function apiRequest(endpoint: string, options: RequestInit = {}): Promi export interface ApiUser { id: string; name: string; + fullName?: string; email: string; role: string; status: string; created_at: string; - avatarUrl?: string; // Add this + avatarUrl?: string; + phone?: string; + bio?: string; } export interface ApiJob { @@ -154,7 +158,7 @@ export const authApi = { }); }, getCurrentUser: () => { - return apiRequest("/api/v1/users/me"); + return apiRequest("/api/v1/users/me"); }, }; @@ -308,6 +312,7 @@ export const jobsApi = { location?: string; type?: string; workMode?: string; + companyId?: string; }) => { const query = new URLSearchParams(); if (params.page) query.append("page", params.page.toString()); @@ -316,6 +321,7 @@ export const jobsApi = { if (params.location) query.append("location", params.location); if (params.type) query.append("type", params.type); if (params.workMode) query.append("workMode", params.workMode); + if (params.companyId) query.append("companyId", params.companyId); return apiRequest<{ data: ApiJob[]; @@ -338,6 +344,12 @@ export const applicationsApi = { method: "POST", body: JSON.stringify(data), }), + list: (params: { jobId?: string; companyId?: string }) => { + const query = new URLSearchParams(); + if (params.jobId) query.append("jobId", params.jobId); + if (params.companyId) query.append("companyId", params.companyId); + return apiRequest(`/api/v1/applications?${query.toString()}`); + }, }; // --- Helper Functions --- diff --git a/frontend/src/lib/auth.ts b/frontend/src/lib/auth.ts index 0ca8948..5e64982 100644 --- a/frontend/src/lib/auth.ts +++ b/frontend/src/lib/auth.ts @@ -1,4 +1,5 @@ -import type { User } from "./types"; +import { User } from "./types"; +export type { User }; const AUTH_KEY = "job-portal-auth"; const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8521/api/v1";