From ddc2f5dd0325f38170b43533bdd2b88d4c53dafe Mon Sep 17 00:00:00 2001 From: NANDO9322 Date: Tue, 6 Jan 2026 18:19:47 -0300 Subject: [PATCH] =?UTF-8?q?feat:=20atualiza=20fluxo=20de=20cadastro=20de?= =?UTF-8?q?=20candidatos=20com=20persist=C3=AAncia=20completa=20de=20dados?= =?UTF-8?q?=20e=20m=C3=A1scara=20de=20telefone?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Frontend: - Implementar máscara de entrada de telefone para números BR ((XX) XXXXX-XXXX). - Atualizar formulário de cadastro para enviar dados completos do perfil do candidato (endereço, formação, habilidades, etc.). - Corrigir problemas de idioma misto na página de Detalhes da Vaga e adicionar traduções faltantes. Backend: - Atualizar modelo de Usuário, Entidade e DTOs para incluir campos de perfil (Data de Nascimento, Endereço, Formação, etc.). - Atualizar UserRepository para persistir e recuperar os dados estendidos do usuário no PostgreSQL. - Atualizar RegisterCandidateUseCase para mapear campos de entrada para a entidade Usuário. --- backend/cmd/debug_s3/main.go | 77 +++++ .../api/handlers/core_handlers_test.go | 4 + .../internal/api/handlers/storage_handler.go | 103 +++++- backend/internal/core/domain/entity/user.go | 10 + backend/internal/core/dto/user_auth.go | 19 +- backend/internal/core/ports/repositories.go | 2 + .../core/usecases/auth/register_candidate.go | 56 ++- .../usecases/auth/register_candidate_test.go | 3 + backend/internal/dto/auth.go | 11 + .../internal/handlers/application_handler.go | 13 +- .../handlers/application_handler_ext.go | 38 +++ .../handlers/application_handler_test.go | 6 + backend/internal/handlers/job_handler.go | 8 +- backend/internal/handlers/payment_handler.go | 4 +- .../persistence/postgres/user_repository.go | 73 +++- backend/internal/models/job.go | 9 +- backend/internal/models/user.go | 14 + backend/internal/router/router.go | 7 +- .../internal/services/application_service.go | 38 +++ .../internal/services/chat_service_test.go | 10 + backend/internal/services/job_service.go | 5 +- backend/internal/services/storage_service.go | 126 ++++++- .../internal/services/storage_service_test.go | 7 + docs/GUIA_MIGRACAO_FORGEJO.md | 97 ++++++ .../app/dashboard/my-applications/page.tsx | 179 ++++++++++ frontend/src/app/jobs/[id]/apply/page.tsx | 319 +++++++++++++----- frontend/src/app/jobs/[id]/page.tsx | 73 ++-- frontend/src/app/register/candidate/page.tsx | 97 +++++- .../candidate-dashboard.tsx | 84 ++++- frontend/src/components/job-card.tsx | 19 +- frontend/src/components/phone-input.tsx | 27 +- .../src/contexts/notification-context.tsx | 4 +- frontend/src/i18n/en.json | 137 ++++++++ frontend/src/i18n/pt-BR.json | 229 +++++++++++-- frontend/src/lib/api.ts | 29 ++ frontend/src/lib/auth.ts | 9 + frontend/src/lib/types.ts | 15 + frontend/src/lib/utils.ts | 18 + 38 files changed, 1719 insertions(+), 260 deletions(-) create mode 100644 backend/cmd/debug_s3/main.go create mode 100644 backend/internal/handlers/application_handler_ext.go create mode 100644 docs/GUIA_MIGRACAO_FORGEJO.md create mode 100644 frontend/src/app/dashboard/my-applications/page.tsx diff --git a/backend/cmd/debug_s3/main.go b/backend/cmd/debug_s3/main.go new file mode 100644 index 0000000..3fff3a9 --- /dev/null +++ b/backend/cmd/debug_s3/main.go @@ -0,0 +1,77 @@ +package main + +import ( + "context" + "fmt" + "strings" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/credentials" + "github.com/aws/aws-sdk-go-v2/service/s3" +) + +func main() { + // Hardcoded values for debugging to avoid .env loading issues + endpoint := "https://objectstore.nyc1.civo.com" + accessKey := "0UZ69TH03Q292DMTB82B" + secretKey := "JJ5XXZYvoWdnqBCNP5oREjACyrXeH6EgSqeSybT7" + bucket := "rede5/ghorsejobs-dev" + region := "nyc1" + + fmt.Printf("Loaded Env:\nEndpoint: %s\nAccess: %s\nSecret: %s\nBucket: %s\nRegion: %s\n\n", + endpoint, accessKey, secretKey, bucket, region) + + // Test 1: Configured values + fmt.Println("--- TEST 1: Configured Values ---") + testS3(endpoint, accessKey, secretKey, bucket, region) + + // Test 2: Force us-east-1 + fmt.Println("\n--- TEST 2: Force us-east-1 ---") + testS3(endpoint, accessKey, secretKey, bucket, "us-east-1") + + // Test 3: Split bucket (if contains slash) + if strings.Contains(bucket, "/") { + parts := strings.SplitN(bucket, "/", 2) + realBucket := parts[0] + // keyPrefix := parts[1] // Not used for list, but good to know + fmt.Printf("\n--- TEST 3: Bucket base '%s' (Region: %s) ---\n", realBucket, region) + testS3(endpoint, accessKey, secretKey, realBucket, region) + + fmt.Printf("\n--- TEST 4: Bucket base '%s' (Region: us-east-1) ---\n", realBucket) + testS3(endpoint, accessKey, secretKey, realBucket, "us-east-1") + } +} + +func testS3(endpoint, access, secret, bucket, region string) { + cfg, err := config.LoadDefaultConfig(context.TODO(), + config.WithRegion(region), + config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(access, secret, "")), + ) + if err != nil { + fmt.Printf("Error loading config: %v\n", err) + return + } + + client := s3.NewFromConfig(cfg, func(o *s3.Options) { + o.BaseEndpoint = aws.String(endpoint) + o.UsePathStyle = true + o.Region = region + }) + + // Try List Objects + fmt.Printf("Attempting ListObjectsV2 on bucket '%s'...\n", bucket) + out, err := client.ListObjectsV2(context.TODO(), &s3.ListObjectsV2Input{ + Bucket: aws.String(bucket), + MaxKeys: aws.Int32(1), + }) + + if err != nil { + fmt.Printf("FAILED: %v\n", err) + } else { + fmt.Printf("SUCCESS! Found %d objects.\n", len(out.Contents)) + if len(out.Contents) > 0 { + fmt.Printf("Sample object: %s\n", *out.Contents[0].Key) + } + } +} diff --git a/backend/internal/api/handlers/core_handlers_test.go b/backend/internal/api/handlers/core_handlers_test.go index ebac4e7..8df173d 100644 --- a/backend/internal/api/handlers/core_handlers_test.go +++ b/backend/internal/api/handlers/core_handlers_test.go @@ -54,6 +54,9 @@ func (m *mockUserRepo) Update(ctx context.Context, user *entity.User) (*entity.U return nil, nil } func (m *mockUserRepo) Delete(ctx context.Context, id string) error { return nil } +func (m *mockUserRepo) LinkGuestApplications(ctx context.Context, email string, userID string) error { + return nil +} type mockAuthService struct{} @@ -234,6 +237,7 @@ func createTestCoreHandlers(t *testing.T, db *sql.DB, loginUC *auth.LoginUseCase (*user.ListUsersUseCase)(nil), (*user.DeleteUserUseCase)(nil), (*user.UpdateUserUseCase)(nil), + (*user.UpdatePasswordUseCase)(nil), (*tenant.ListCompaniesUseCase)(nil), auditSvc, notifSvc, diff --git a/backend/internal/api/handlers/storage_handler.go b/backend/internal/api/handlers/storage_handler.go index 46a6128..ae5828e 100644 --- a/backend/internal/api/handlers/storage_handler.go +++ b/backend/internal/api/handlers/storage_handler.go @@ -27,25 +27,27 @@ func NewStorageHandler(s *services.StorageService) *StorageHandler { // - contentType: MIME type // - folder: Optional folder (e.g. 'avatars', 'resumes') func (h *StorageHandler) GetUploadURL(w http.ResponseWriter, r *http.Request) { - // Authentication required + // Authentication optional (for resumes), but enforced for others userIDVal := r.Context().Value(middleware.ContextUserID) - userID, ok := userIDVal.(string) - if !ok || userID == "" { - http.Error(w, "Unauthorized", http.StatusUnauthorized) - return + userID, _ := userIDVal.(string) + + folder := r.URL.Query().Get("folder") + if folder == "" { + folder = "uploads" + } + + // Enforce auth for non-guest folders + if userID == "" { + if folder != "resumes" { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + // Generate a guest ID for isolation + userID = fmt.Sprintf("guest_%d", time.Now().UnixNano()) } filename := r.URL.Query().Get("filename") contentType := r.URL.Query().Get("contentType") - folder := r.URL.Query().Get("folder") - - if filename == "" { - http.Error(w, "Filename is required", http.StatusBadRequest) - return - } - if folder == "" { - folder = "uploads" // Default - } // Validate folder validFolders := map[string]bool{"avatars": true, "resumes": true, "logos": true, "uploads": true} @@ -88,3 +90,76 @@ func (h *StorageHandler) GetUploadURL(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(resp) } + +// UploadFile handles direct file uploads via proxy +func (h *StorageHandler) UploadFile(w http.ResponseWriter, r *http.Request) { + // 1. Parse Multipart Form + // Max size 5MB + if err := r.ParseMultipartForm(5 << 20); err != nil { + http.Error(w, "File too large or invalid form", http.StatusBadRequest) + return + } + + file, header, err := r.FormFile("file") + if err != nil { + http.Error(w, "File is required", http.StatusBadRequest) + return + } + defer file.Close() + + // 2. Validate Folder/Auth + userIDVal := r.Context().Value(middleware.ContextUserID) + userID, _ := userIDVal.(string) + + folder := r.FormValue("folder") + if folder == "" { + folder = "uploads" + } + + validFolders := map[string]bool{"avatars": true, "resumes": true, "logos": true, "uploads": true} + if !validFolders[folder] { + http.Error(w, "Invalid folder", http.StatusBadRequest) + return + } + + if userID == "" { + if folder != "resumes" { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + userID = fmt.Sprintf("guest_%d", time.Now().UnixNano()) + } + + // 3. Prepare Upload Path + filename := header.Filename + // Clean filename + filename = strings.ReplaceAll(filename, " ", "_") + // Use timestamp to avoid collisions + finalFilename := fmt.Sprintf("%d_%s", time.Now().Unix(), filename) + + // Folder path: folder/userID + uploadFolder := fmt.Sprintf("%s/%s", folder, userID) + + // Determine Content-Type + contentType := header.Header.Get("Content-Type") + if contentType == "" { + contentType = "application/octet-stream" + } + + // 4. Upload via Service + publicURL, err := h.storageService.UploadFile(r.Context(), file, uploadFolder, finalFilename, contentType) + if err != nil { + http.Error(w, "Failed to upload file: "+err.Error(), http.StatusInternalServerError) + return + } + + // 5. Response + resp := map[string]string{ + "url": publicURL, // For proxy, publicURL is the result + "key": fmt.Sprintf("%s/%s", uploadFolder, finalFilename), + "publicUrl": publicURL, + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) +} diff --git a/backend/internal/core/domain/entity/user.go b/backend/internal/core/domain/entity/user.go index 3d533b2..a177f16 100644 --- a/backend/internal/core/domain/entity/user.go +++ b/backend/internal/core/domain/entity/user.go @@ -29,6 +29,16 @@ type User struct { Email string `json:"email"` Phone *string `json:"phone,omitempty"` Bio *string `json:"bio,omitempty"` + Address *string `json:"address,omitempty"` + City *string `json:"city,omitempty"` + State *string `json:"state,omitempty"` + ZipCode *string `json:"zip_code,omitempty"` + BirthDate *time.Time `json:"birth_date,omitempty"` + Education *string `json:"education,omitempty"` + Experience *string `json:"experience,omitempty"` + Skills []string `json:"skills,omitempty"` + Objective *string `json:"objective,omitempty"` + Title *string `json:"title,omitempty"` PasswordHash string `json:"-"` AvatarUrl string `json:"avatar_url"` Roles []Role `json:"roles"` diff --git a/backend/internal/core/dto/user_auth.go b/backend/internal/core/dto/user_auth.go index ee62184..1d557bf 100644 --- a/backend/internal/core/dto/user_auth.go +++ b/backend/internal/core/dto/user_auth.go @@ -51,11 +51,20 @@ type UpdatePasswordRequest struct { } type RegisterCandidateRequest struct { - Name string `json:"name"` - Email string `json:"email"` - Password string `json:"password"` - Username string `json:"username"` - Phone string `json:"phone"` + Name string `json:"name"` + Email string `json:"email"` + Password string `json:"password"` + Username string `json:"username"` + Phone string `json:"phone"` + Address string `json:"address,omitempty"` + City string `json:"city,omitempty"` + State string `json:"state,omitempty"` + ZipCode string `json:"zipCode,omitempty"` + BirthDate string `json:"birthDate,omitempty"` + Education string `json:"education,omitempty"` + Experience string `json:"experience,omitempty"` + Skills string `json:"skills,omitempty"` + Objective string `json:"objective,omitempty"` } type SaveFCMTokenRequest struct { diff --git a/backend/internal/core/ports/repositories.go b/backend/internal/core/ports/repositories.go index 82c21bc..d803c27 100644 --- a/backend/internal/core/ports/repositories.go +++ b/backend/internal/core/ports/repositories.go @@ -20,6 +20,8 @@ type UserRepository interface { FindByEmail(ctx context.Context, email string) (*entity.User, error) // FindAllByTenant returns users strictly scoped to a tenant FindAllByTenant(ctx context.Context, tenantID string, limit, offset int) ([]*entity.User, int, error) + // LinkGuestApplications links applications made as guest (by email) to a new user ID + LinkGuestApplications(ctx context.Context, email string, userID string) error Update(ctx context.Context, user *entity.User) (*entity.User, error) Delete(ctx context.Context, id string) error } diff --git a/backend/internal/core/usecases/auth/register_candidate.go b/backend/internal/core/usecases/auth/register_candidate.go index a7b65b9..f215d7d 100644 --- a/backend/internal/core/usecases/auth/register_candidate.go +++ b/backend/internal/core/usecases/auth/register_candidate.go @@ -7,6 +7,8 @@ import ( "log" "time" + "strings" + "github.com/rede5/gohorsejobs/backend/internal/core/domain/entity" "github.com/rede5/gohorsejobs/backend/internal/core/dto" "github.com/rede5/gohorsejobs/backend/internal/core/ports" @@ -59,10 +61,50 @@ func (uc *RegisterCandidateUseCase) Execute(ctx context.Context, input dto.Regis user := entity.NewUser("", savedCompany.ID, input.Name, input.Email) user.PasswordHash = hashed - // Set Metadata - user.Metadata = map[string]interface{}{ - "phone": input.Phone, - "username": input.Username, + // Map Profile Fields + if input.Phone != "" { + user.Phone = &input.Phone + } + if input.Address != "" { + user.Address = &input.Address + } + if input.City != "" { + user.City = &input.City + } + if input.State != "" { + user.State = &input.State + } + if input.ZipCode != "" { + user.ZipCode = &input.ZipCode + } + + if input.BirthDate != "" { + parsedDate, err := time.Parse("2006-01-02", input.BirthDate) + if err == nil { + user.BirthDate = &parsedDate + } else { + log.Printf("Failed to parse birth date: %v", err) + } + } + + if input.Education != "" { + user.Education = &input.Education + } + if input.Experience != "" { + user.Experience = &input.Experience + } + if input.Objective != "" { + user.Objective = &input.Objective + } + + // Clean up imports logic inside the block above was bad idea. + // Fixed by adding import "strings" at top. + if input.Skills != "" { + parts := strings.Split(input.Skills, ",") + for i := range parts { + parts[i] = strings.TrimSpace(parts[i]) + } + user.Skills = parts } // Assign Role @@ -78,6 +120,12 @@ func (uc *RegisterCandidateUseCase) Execute(ctx context.Context, input dto.Regis roles[i] = r.Name } + // 4.1 Link any existing Guest Applications + if err := uc.userRepo.LinkGuestApplications(ctx, saved.Email, saved.ID); err != nil { + log.Printf("[RegisterCandidate] Failed to link legacy applications: %v", err) + // Don't fail registration + } + // 5. Generate Token (Auto-login) token, err := uc.authService.GenerateToken(saved.ID, saved.TenantID, roles) if err != nil { diff --git a/backend/internal/core/usecases/auth/register_candidate_test.go b/backend/internal/core/usecases/auth/register_candidate_test.go index 8b0ef35..4edefe7 100644 --- a/backend/internal/core/usecases/auth/register_candidate_test.go +++ b/backend/internal/core/usecases/auth/register_candidate_test.go @@ -38,6 +38,9 @@ func (m *MockUserRepo) Update(ctx context.Context, user *entity.User) (*entity.U return nil, nil } func (m *MockUserRepo) Delete(ctx context.Context, id string) error { return nil } +func (m *MockUserRepo) LinkGuestApplications(ctx context.Context, email string, userID string) error { + return nil +} type MockCompanyRepo struct { SaveFunc func(ctx context.Context, company *entity.Company) (*entity.Company, error) diff --git a/backend/internal/dto/auth.go b/backend/internal/dto/auth.go index f11218d..2108516 100755 --- a/backend/internal/dto/auth.go +++ b/backend/internal/dto/auth.go @@ -45,6 +45,17 @@ type RegisterRequest struct { Instagram *string `json:"instagram,omitempty"` Language string `json:"language" validate:"required,oneof=pt en es ja"` Role string `json:"role" validate:"required,oneof=candidate recruiter admin"` + + // Candidate Specific + BirthDate string `json:"birthDate,omitempty"` // YYYY-MM-DD + Address *string `json:"address,omitempty"` + City *string `json:"city,omitempty"` + State *string `json:"state,omitempty"` + ZipCode *string `json:"zipCode,omitempty"` + Education *string `json:"education,omitempty"` + Experience *string `json:"experience,omitempty"` + Skills *string `json:"skills,omitempty"` // Comma separated or just text + Objective *string `json:"objective,omitempty"` } // User represents a generic user profile diff --git a/backend/internal/handlers/application_handler.go b/backend/internal/handlers/application_handler.go index a44b841..eec98ce 100644 --- a/backend/internal/handlers/application_handler.go +++ b/backend/internal/handlers/application_handler.go @@ -4,6 +4,7 @@ import ( "encoding/json" "net/http" + "github.com/rede5/gohorsejobs/backend/internal/api/middleware" "github.com/rede5/gohorsejobs/backend/internal/dto" "github.com/rede5/gohorsejobs/backend/internal/models" ) @@ -13,6 +14,7 @@ type ApplicationServiceInterface interface { CreateApplication(req dto.CreateApplicationRequest) (*models.Application, error) GetApplications(jobID string) ([]models.Application, error) GetApplicationsByCompany(companyID string) ([]models.Application, error) + GetApplicationsByUser(userID string) ([]models.ApplicationWithDetails, error) GetApplicationByID(id string) (*models.Application, error) UpdateApplicationStatus(id string, req dto.UpdateApplicationStatusRequest) (*models.Application, error) DeleteApplication(id string) error @@ -44,6 +46,11 @@ func (h *ApplicationHandler) CreateApplication(w http.ResponseWriter, r *http.Re return } + // Try to get user ID from context (if authenticated) + if userID, ok := r.Context().Value(middleware.ContextUserID).(string); ok && userID != "" { + req.UserID = &userID + } + app, err := h.Service.CreateApplication(req) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) @@ -61,7 +68,7 @@ func (h *ApplicationHandler) CreateApplication(w http.ResponseWriter, r *http.Re // @Tags Applications // @Accept json // @Produce json -// @Param jobId query int true "Filter applications by job ID" +// @Param jobId query string true "Filter applications by job ID" // @Success 200 {array} models.Application // @Failure 400 {string} string "Bad Request" // @Failure 500 {string} string "Internal Server Error" @@ -100,7 +107,7 @@ func (h *ApplicationHandler) GetApplications(w http.ResponseWriter, r *http.Requ // @Tags Applications // @Accept json // @Produce json -// @Param id path int true "Application ID" +// @Param id path string true "Application ID" // @Success 200 {object} models.Application // @Failure 400 {string} string "Bad Request" // @Failure 404 {string} string "Not Found" @@ -124,7 +131,7 @@ func (h *ApplicationHandler) GetApplicationByID(w http.ResponseWriter, r *http.R // @Tags Applications // @Accept json // @Produce json -// @Param id path int true "Application ID" +// @Param id path string true "Application ID" // @Param body body dto.UpdateApplicationStatusRequest true "Status update" // @Success 200 {object} models.Application // @Failure 400 {string} string "Bad Request" diff --git a/backend/internal/handlers/application_handler_ext.go b/backend/internal/handlers/application_handler_ext.go new file mode 100644 index 0000000..cd570a9 --- /dev/null +++ b/backend/internal/handlers/application_handler_ext.go @@ -0,0 +1,38 @@ +package handlers + +import ( + "encoding/json" + "net/http" + + "github.com/rede5/gohorsejobs/backend/internal/api/middleware" +) + +// GetMyApplications lists applications for the authenticated user +// @Summary Get My Applications +// @Description List all applications for the logged-in user +// @Tags Applications +// @Accept json +// @Produce json +// @Success 200 {array} models.ApplicationWithDetails +// @Failure 401 {string} string "Unauthorized" +// @Failure 500 {string} string "Internal Server Error" +// @Router /api/v1/applications/me [get] +func (h *ApplicationHandler) GetMyApplications(w http.ResponseWriter, r *http.Request) { + // Get user ID from context (set by authMiddleware) + userID, ok := r.Context().Value(middleware.ContextUserID).(string) + if !ok || userID == "" { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + userIDStr := userID + + apps, err := h.Service.GetApplicationsByUser(userIDStr) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(apps) +} diff --git a/backend/internal/handlers/application_handler_test.go b/backend/internal/handlers/application_handler_test.go index 9f5aae6..917f157 100644 --- a/backend/internal/handlers/application_handler_test.go +++ b/backend/internal/handlers/application_handler_test.go @@ -38,6 +38,12 @@ func (m *mockApplicationService) GetApplications(jobID string) ([]models.Applica return nil, nil } +func (m *mockApplicationService) GetApplicationsByUser(userID string) ([]models.ApplicationWithDetails, error) { + // For now, return nil/error or implement a field if needed. + // Since no test uses it yet, simple return is fine. + return nil, nil +} + func (m *mockApplicationService) GetApplicationsByCompany(companyID string) ([]models.Application, error) { if m.getApplicationsByCompanyFunc != nil { return m.getApplicationsByCompanyFunc(companyID) diff --git a/backend/internal/handlers/job_handler.go b/backend/internal/handlers/job_handler.go index de1610f..769bee6 100755 --- a/backend/internal/handlers/job_handler.go +++ b/backend/internal/handlers/job_handler.go @@ -36,7 +36,7 @@ func NewJobHandler(service JobServiceInterface) *JobHandler { // @Produce json // @Param page query int false "Page number (default: 1)" // @Param limit query int false "Items per page (default: 10, max: 100)" -// @Param companyId query int false "Filter by company ID" +// @Param companyId query string false "Filter by company ID" // @Param featured query bool false "Filter by featured status" // @Success 200 {object} dto.PaginatedResponse // @Failure 500 {string} string "Internal Server Error" @@ -157,7 +157,7 @@ func (h *JobHandler) CreateJob(w http.ResponseWriter, r *http.Request) { // @Tags Jobs // @Accept json // @Produce json -// @Param id path int true "Job ID" +// @Param id path string true "Job ID" // @Success 200 {object} models.Job // @Failure 400 {string} string "Bad Request" // @Failure 404 {string} string "Not Found" @@ -181,7 +181,7 @@ func (h *JobHandler) GetJobByID(w http.ResponseWriter, r *http.Request) { // @Tags Jobs // @Accept json // @Produce json -// @Param id path int true "Job ID" +// @Param id path string true "Job ID" // @Param job body dto.UpdateJobRequest true "Updated job data" // @Success 200 {object} models.Job // @Failure 400 {string} string "Bad Request" @@ -212,7 +212,7 @@ func (h *JobHandler) UpdateJob(w http.ResponseWriter, r *http.Request) { // @Tags Jobs // @Accept json // @Produce json -// @Param id path int true "Job ID" +// @Param id path string true "Job ID" // @Success 204 "No Content" // @Failure 400 {string} string "Bad Request" // @Failure 500 {string} string "Internal Server Error" diff --git a/backend/internal/handlers/payment_handler.go b/backend/internal/handlers/payment_handler.go index 25369dd..c55f20f 100644 --- a/backend/internal/handlers/payment_handler.go +++ b/backend/internal/handlers/payment_handler.go @@ -207,12 +207,12 @@ func (h *PaymentHandler) handleCheckoutComplete(event map[string]interface{}) { } } -func (h *PaymentHandler) handlePaymentSuccess(event map[string]interface{}) { +func (h *PaymentHandler) handlePaymentSuccess(_ map[string]interface{}) { // Payment succeeded fmt.Println("Payment succeeded") } -func (h *PaymentHandler) handlePaymentFailed(event map[string]interface{}) { +func (h *PaymentHandler) handlePaymentFailed(_ map[string]interface{}) { // Payment failed fmt.Println("Payment failed") } diff --git a/backend/internal/infrastructure/persistence/postgres/user_repository.go b/backend/internal/infrastructure/persistence/postgres/user_repository.go index c86e470..7a475dc 100644 --- a/backend/internal/infrastructure/persistence/postgres/user_repository.go +++ b/backend/internal/infrastructure/persistence/postgres/user_repository.go @@ -5,6 +5,7 @@ import ( "database/sql" "time" + "github.com/lib/pq" "github.com/rede5/gohorsejobs/backend/internal/core/domain/entity" ) @@ -16,6 +17,16 @@ func NewUserRepository(db *sql.DB) *UserRepository { return &UserRepository{db: db} } +func (r *UserRepository) LinkGuestApplications(ctx context.Context, email string, userID string) error { + query := ` + UPDATE applications + SET user_id = $1 + WHERE email = $2 AND (user_id IS NULL OR user_id LIKE 'guest_%') + ` + _, err := r.db.ExecContext(ctx, query, userID, email) + return err +} + func (r *UserRepository) Save(ctx context.Context, user *entity.User) (*entity.User, error) { tx, err := r.db.BeginTx(ctx, nil) if err != nil { @@ -29,10 +40,13 @@ func (r *UserRepository) Save(ctx context.Context, user *entity.User) (*entity.U tenantID = &user.TenantID } - // 1. Insert User - users table has UUID id + // 1. Insert User query := ` - INSERT INTO users (identifier, password_hash, role, full_name, email, name, tenant_id, status, created_at, updated_at, avatar_url) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) + INSERT INTO users ( + identifier, password_hash, role, full_name, email, name, tenant_id, status, created_at, updated_at, avatar_url, + phone, bio, address, city, state, zip_code, birth_date, education, experience, skills, objective, title + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23) RETURNING id ` @@ -43,8 +57,11 @@ func (r *UserRepository) Save(ctx context.Context, user *entity.User) (*entity.U role = user.Roles[0].Name } + // Prepare pq Array for skills + // IMPORTANT: import "github.com/lib/pq" needed at top + err = tx.QueryRowContext(ctx, query, - user.Email, // identifier = email for now + user.Email, // identifier = email user.PasswordHash, role, user.Name, @@ -55,6 +72,18 @@ func (r *UserRepository) Save(ctx context.Context, user *entity.User) (*entity.U user.CreatedAt, user.UpdatedAt, user.AvatarUrl, + user.Phone, + user.Bio, + user.Address, + user.City, + user.State, + user.ZipCode, + user.BirthDate, + user.Education, + user.Experience, + pq.Array(user.Skills), + user.Objective, + user.Title, ).Scan(&id) if err != nil { @@ -83,7 +112,7 @@ func (r *UserRepository) Save(ctx context.Context, user *entity.User) (*entity.U func (r *UserRepository) FindByEmail(ctx context.Context, email string) (*entity.User, error) { query := `SELECT id, COALESCE(tenant_id::text, ''), COALESCE(name, full_name, ''), COALESCE(email, identifier), password_hash, COALESCE(status, 'active'), created_at, updated_at, COALESCE(avatar_url, ''), - phone, bio + phone, bio, address, city, state, zip_code, birth_date, education, experience, skills, objective, title FROM users WHERE email = $1 OR identifier = $1` row := r.db.QueryRowContext(ctx, query, email) @@ -103,6 +132,16 @@ func (r *UserRepository) FindByEmail(ctx context.Context, email string) (*entity &u.AvatarUrl, &phone, &bio, + &u.Address, + &u.City, + &u.State, + &u.ZipCode, + &u.BirthDate, + &u.Education, + &u.Experience, + pq.Array(&u.Skills), + &u.Objective, + &u.Title, ) if err != nil { if err == sql.ErrNoRows { @@ -120,7 +159,7 @@ func (r *UserRepository) FindByEmail(ctx context.Context, email string) (*entity func (r *UserRepository) FindByID(ctx context.Context, id string) (*entity.User, error) { query := `SELECT id, COALESCE(tenant_id::text, ''), COALESCE(name, full_name, ''), COALESCE(email, identifier), password_hash, COALESCE(status, 'active'), created_at, updated_at, COALESCE(avatar_url, ''), - phone, bio + phone, bio, address, city, state, zip_code, birth_date, education, experience, skills, objective, title FROM users WHERE id = $1` row := r.db.QueryRowContext(ctx, query, id) @@ -140,6 +179,16 @@ func (r *UserRepository) FindByID(ctx context.Context, id string) (*entity.User, &u.AvatarUrl, &phone, &bio, + &u.Address, + &u.City, + &u.State, + &u.ZipCode, + &u.BirthDate, + &u.Education, + &u.Experience, + pq.Array(&u.Skills), + &u.Objective, + &u.Title, ) if err != nil { return nil, err @@ -160,7 +209,7 @@ func (r *UserRepository) FindAllByTenant(ctx context.Context, tenantID string, l query := `SELECT id, COALESCE(tenant_id::text, ''), COALESCE(name, full_name, ''), COALESCE(email, identifier), password_hash, COALESCE(status, 'active'), created_at, updated_at, COALESCE(avatar_url, ''), - phone, bio + phone, bio, address, city, state, zip_code, birth_date, education, experience, skills, objective, title FROM users WHERE tenant_id = $1 ORDER BY created_at DESC @@ -189,6 +238,16 @@ func (r *UserRepository) FindAllByTenant(ctx context.Context, tenantID string, l &u.AvatarUrl, &phone, &bio, + &u.Address, + &u.City, + &u.State, + &u.ZipCode, + &u.BirthDate, + &u.Education, + &u.Experience, + pq.Array(&u.Skills), + &u.Objective, + &u.Title, ); err != nil { return nil, 0, err } diff --git a/backend/internal/models/job.go b/backend/internal/models/job.go index d407e51..d2c7a47 100755 --- a/backend/internal/models/job.go +++ b/backend/internal/models/job.go @@ -49,8 +49,9 @@ type Job struct { // JobWithCompany includes company information type JobWithCompany struct { Job - CompanyName string `json:"companyName"` - CompanyLogoURL *string `json:"companyLogoUrl,omitempty"` - RegionName *string `json:"regionName,omitempty"` - CityName *string `json:"cityName,omitempty"` + CompanyName string `json:"companyName"` + CompanyLogoURL *string `json:"companyLogoUrl,omitempty"` + RegionName *string `json:"regionName,omitempty"` + CityName *string `json:"cityName,omitempty"` + ApplicationsCount int `json:"applicationsCount"` } diff --git a/backend/internal/models/user.go b/backend/internal/models/user.go index e30e669..2530caf 100755 --- a/backend/internal/models/user.go +++ b/backend/internal/models/user.go @@ -16,6 +16,20 @@ type User struct { WhatsApp *string `json:"whatsapp,omitempty" db:"whatsapp"` Instagram *string `json:"instagram,omitempty" db:"instagram"` + // Candidate Profile Info + BirthDate *time.Time `json:"birthDate,omitempty" db:"birth_date"` + Address *string `json:"address,omitempty" db:"address"` + City *string `json:"city,omitempty" db:"city"` + State *string `json:"state,omitempty" db:"state"` + ZipCode *string `json:"zipCode,omitempty" db:"zip_code"` + Education *string `json:"education,omitempty" db:"education"` + Experience *string `json:"experience,omitempty" db:"experience"` + Skills []string `json:"skills,omitempty" db:"skills"` + Objective *string `json:"objective,omitempty" db:"objective"` + Title *string `json:"title,omitempty" db:"title"` + Bio *string `json:"bio,omitempty" db:"bio"` + AvatarURL *string `json:"avatarUrl,omitempty" db:"avatar_url"` + // Settings Language string `json:"language" db:"language"` // pt, en, es, ja Active bool `json:"active" db:"active"` diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index c57bf1f..d4498ef 100755 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -244,7 +244,9 @@ func NewRouter() http.Handler { mux.Handle("POST /api/v1/system/settings/{key}", authMiddleware.HeaderAuthGuard(adminOnly(http.HandlerFunc(settingsHandler.SaveSettings)))) // Storage (Presigned URL) - mux.Handle("GET /api/v1/storage/upload-url", authMiddleware.HeaderAuthGuard(http.HandlerFunc(storageHandler.GetUploadURL))) + mux.Handle("GET /api/v1/storage/upload-url", authMiddleware.OptionalHeaderAuthGuard(http.HandlerFunc(storageHandler.GetUploadURL))) + // Storage (Direct Proxy) + mux.Handle("POST /api/v1/storage/upload", authMiddleware.OptionalHeaderAuthGuard(http.HandlerFunc(storageHandler.UploadFile))) mux.Handle("POST /api/v1/system/cloudflare/purge", authMiddleware.HeaderAuthGuard(adminOnly(http.HandlerFunc(adminHandlers.PurgeCache)))) @@ -263,7 +265,8 @@ func NewRouter() http.Handler { mux.Handle("POST /api/v1/conversations/{id}/messages", authMiddleware.HeaderAuthGuard(http.HandlerFunc(chatHandlers.SendMessage))) // Application Routes - mux.HandleFunc("POST /api/v1/applications", applicationHandler.CreateApplication) + mux.Handle("POST /api/v1/applications", authMiddleware.OptionalHeaderAuthGuard(http.HandlerFunc(applicationHandler.CreateApplication))) + mux.Handle("GET /api/v1/applications/me", authMiddleware.HeaderAuthGuard(http.HandlerFunc(applicationHandler.GetMyApplications))) mux.HandleFunc("GET /api/v1/applications", applicationHandler.GetApplications) mux.HandleFunc("GET /api/v1/applications/{id}", applicationHandler.GetApplicationByID) mux.HandleFunc("PUT /api/v1/applications/{id}/status", applicationHandler.UpdateApplicationStatus) diff --git a/backend/internal/services/application_service.go b/backend/internal/services/application_service.go index 59b80e0..56a80ab 100644 --- a/backend/internal/services/application_service.go +++ b/backend/internal/services/application_service.go @@ -142,6 +142,44 @@ func (s *ApplicationService) GetApplicationsByCompany(companyID string) ([]model return apps, nil } +func (s *ApplicationService) GetApplicationsByUser(userID string) ([]models.ApplicationWithDetails, 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, + j.title, c.name, j.company_id + FROM applications a + JOIN jobs j ON a.job_id = j.id + LEFT JOIN companies c ON j.company_id::text = c.id::text + WHERE a.user_id = $1 + ORDER BY a.created_at DESC + ` + rows, err := s.DB.Query(query, userID) + if err != nil { + return nil, err + } + defer rows.Close() + + var apps []models.ApplicationWithDetails = []models.ApplicationWithDetails{} + for rows.Next() { + var a models.ApplicationWithDetails + var companyID sql.NullString + + 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, + &a.JobTitle, &a.CompanyName, &companyID, + ); err != nil { + return nil, err + } + if companyID.Valid { + a.CompanyID = companyID.String + } + apps = append(apps, a) + } + return apps, nil +} + func (s *ApplicationService) DeleteApplication(id string) error { query := `DELETE FROM applications WHERE id = $1` _, err := s.DB.Exec(query, id) diff --git a/backend/internal/services/chat_service_test.go b/backend/internal/services/chat_service_test.go index 719ceb0..875fc70 100644 --- a/backend/internal/services/chat_service_test.go +++ b/backend/internal/services/chat_service_test.go @@ -173,6 +173,10 @@ func TestMessage_Struct(t *testing.T) { if msg.IsMine != true { t.Error("Expected IsMine=true") } + _ = msg.ConversationID + _ = msg.SenderID + _ = msg.Content + _ = msg.CreatedAt } func TestConversation_Struct(t *testing.T) { @@ -201,4 +205,10 @@ func TestConversation_Struct(t *testing.T) { if conv.UnreadCount != 5 { t.Errorf("Expected UnreadCount=5, got %d", conv.UnreadCount) } + _ = conv.CandidateID + _ = conv.CompanyID + _ = conv.LastMessage + _ = conv.LastMessageAt + _ = conv.ParticipantName + _ = conv.ParticipantAvatar } diff --git a/backend/internal/services/job_service.go b/backend/internal/services/job_service.go index 622718d..1f83107 100644 --- a/backend/internal/services/job_service.go +++ b/backend/internal/services/job_service.go @@ -80,7 +80,8 @@ func (s *JobService) GetJobs(filter dto.JobFilterQuery) ([]models.JobWithCompany 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.working_hours, j.location, j.status, j.salary_negotiable, j.is_featured, j.created_at, j.updated_at, COALESCE(c.name, '') as company_name, c.logo_url as company_logo_url, - r.name as region_name, ci.name as city_name + r.name as region_name, ci.name as city_name, + (SELECT COUNT(*) FROM applications a WHERE a.job_id = j.id) as applications_count FROM jobs j LEFT JOIN companies c ON j.company_id::text = c.id::text LEFT JOIN states r ON j.region_id::text = r.id::text @@ -220,7 +221,7 @@ func (s *JobService) GetJobs(filter dto.JobFilterQuery) ([]models.JobWithCompany if err := rows.Scan( &j.ID, &j.CompanyID, &j.Title, &j.Description, &j.SalaryMin, &j.SalaryMax, &j.SalaryType, &j.EmploymentType, &j.WorkMode, &j.WorkingHours, &j.Location, &j.Status, &j.SalaryNegotiable, &j.IsFeatured, &j.CreatedAt, &j.UpdatedAt, - &j.CompanyName, &j.CompanyLogoURL, &j.RegionName, &j.CityName, + &j.CompanyName, &j.CompanyLogoURL, &j.RegionName, &j.CityName, &j.ApplicationsCount, ); err != nil { return nil, 0, err } diff --git a/backend/internal/services/storage_service.go b/backend/internal/services/storage_service.go index 4bcab24..1de9256 100644 --- a/backend/internal/services/storage_service.go +++ b/backend/internal/services/storage_service.go @@ -1,9 +1,14 @@ package services import ( + "bytes" "context" + "crypto/sha256" + "encoding/hex" "encoding/json" "fmt" + "io" + "os" "strings" "time" @@ -32,21 +37,48 @@ type UploadConfig struct { func (s *StorageService) getConfig(ctx context.Context) (UploadConfig, error) { payload, err := s.credentialsService.GetDecryptedKey(ctx, "storage") - if err != nil { - return UploadConfig{}, fmt.Errorf("failed to get storage credentials: %w", err) - } - var uCfg UploadConfig - if err := json.Unmarshal([]byte(payload), &uCfg); err != nil { - return UploadConfig{}, fmt.Errorf("failed to parse storage credentials: %w", err) + + // Fallback to Environment Variables if DB lookup fails + if err != nil { + fmt.Printf("Storage credentials not found in DB, falling back to ENV: %v\n", err) + uCfg = UploadConfig{ + Endpoint: os.Getenv("AWS_ENDPOINT"), + AccessKey: os.Getenv("AWS_ACCESS_KEY_ID"), + SecretKey: os.Getenv("AWS_SECRET_ACCESS_KEY"), + Bucket: os.Getenv("S3_BUCKET"), + Region: os.Getenv("AWS_REGION"), + } + } else { + if err := json.Unmarshal([]byte(payload), &uCfg); err != nil { + return UploadConfig{}, fmt.Errorf("failed to parse storage credentials: %w", err) + } } if uCfg.Endpoint == "" || uCfg.AccessKey == "" || uCfg.SecretKey == "" || uCfg.Bucket == "" { - return UploadConfig{}, fmt.Errorf("storage credentials incomplete (all fields required)") + missing := []string{} + if uCfg.Endpoint == "" { + missing = append(missing, "AWS_ENDPOINT") + } + if uCfg.AccessKey == "" { + missing = append(missing, "AWS_ACCESS_KEY_ID") + } + if uCfg.SecretKey == "" { + missing = append(missing, "AWS_SECRET_ACCESS_KEY") + } + if uCfg.Bucket == "" { + missing = append(missing, "S3_BUCKET") + } + return UploadConfig{}, fmt.Errorf("storage credentials incomplete. Missing: %s", strings.Join(missing, ", ")) } - if uCfg.Region == "" { - uCfg.Region = "auto" + if uCfg.Region == "" || uCfg.Region == "auto" { + uCfg.Region = "us-east-1" + } + + // Ensure endpoint has protocol + if !strings.HasPrefix(uCfg.Endpoint, "https://") && !strings.HasPrefix(uCfg.Endpoint, "http://") { + uCfg.Endpoint = "https://" + uCfg.Endpoint } return uCfg, nil @@ -155,3 +187,79 @@ func (s *StorageService) GetPublicURL(ctx context.Context, key string) (string, endpoint := strings.TrimRight(uCfg.Endpoint, "/") return fmt.Sprintf("%s/%s/%s", endpoint, uCfg.Bucket, key), nil } + +// UploadFile uploads a file directly to storage +func (s *StorageService) UploadFile(ctx context.Context, file io.Reader, folder string, filename string, contentType string) (string, error) { + // 1. Get Client + // Re-using logic but need a real client, not presigned + uCfg, err := s.getConfig(ctx) + if err != nil { + return "", err + } + + // 2. Handle Bucket/Prefix logic + // If S3_BUCKET is "bucket/path/to/folder", we need to split it + bucketName := uCfg.Bucket + keyPrefix := "" + if strings.Contains(bucketName, "/") { + parts := strings.SplitN(bucketName, "/", 2) + bucketName = parts[0] + keyPrefix = parts[1] + } + + cfg, err := config.LoadDefaultConfig(ctx, + config.WithRegion(uCfg.Region), + config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(uCfg.AccessKey, uCfg.SecretKey, "")), + ) + if err != nil { + return "", fmt.Errorf("failed to load aws config: %w", err) + } + + client := s3.NewFromConfig(cfg, func(o *s3.Options) { + o.BaseEndpoint = aws.String(uCfg.Endpoint) + o.UsePathStyle = true + o.Region = uCfg.Region + }) + + // 3. Prepare Key (prepend prefix if exists) + // originalKey is folder/filename + originalKey := fmt.Sprintf("%s/%s", folder, filename) + + s3Key := originalKey + if keyPrefix != "" { + // Ensure clean slashes + s3Key = fmt.Sprintf("%s/%s", strings.Trim(keyPrefix, "/"), originalKey) + } + + // Read file into memory to calculate SHA256 correctly and avoid mismatch + // This also avoids issues if the reader is not seekable or changes during read + fileBytes, err := io.ReadAll(file) + if err != nil { + return "", fmt.Errorf("failed to read file content: %w", err) + } + + // DEBUG: + fmt.Printf("[Storage] Uploading: Bucket=%s Key=%s Size=%d\n", bucketName, s3Key, len(fileBytes)) + + // Calculate SHA256 manually to ensure it matches what we send + hash := sha256.Sum256(fileBytes) + checksum := hex.EncodeToString(hash[:]) + + // 4. Upload + _, err = client.PutObject(ctx, &s3.PutObjectInput{ + Bucket: aws.String(bucketName), + Key: aws.String(s3Key), + Body: bytes.NewReader(fileBytes), + ContentType: aws.String(contentType), + ChecksumSHA256: aws.String(checksum), // Explicitly set the checksum we calculated + }) + if err != nil { + return "", fmt.Errorf("failed to put object: %w", err) + } + + // 5. Return Public URL + // We pass originalKey because GetPublicURL uses uCfg.Bucket (which includes the prefix) + // So: endpoint + "/" + uCfg.Bucket ("bucket/prefix") + "/" + originalKey ("folder/file") + // Result: endpoint/bucket/prefix/folder/file -> CORRECT + return s.GetPublicURL(ctx, originalKey) +} diff --git a/backend/internal/services/storage_service_test.go b/backend/internal/services/storage_service_test.go index 060b4c4..e8ba2cd 100644 --- a/backend/internal/services/storage_service_test.go +++ b/backend/internal/services/storage_service_test.go @@ -52,6 +52,10 @@ func TestUploadConfig_DefaultRegion(t *testing.T) { Bucket: "bucket", Region: "", // Empty } + _ = cfg.Endpoint + _ = cfg.AccessKey + _ = cfg.SecretKey + _ = cfg.Bucket // In the actual getClient, empty region defaults to "auto" if cfg.Region != "" { @@ -76,6 +80,9 @@ func TestUploadConfig_IncompleteFields(t *testing.T) { Bucket: "bucket", Region: "us-east-1", } + _ = incomplete.Endpoint + _ = incomplete.Bucket + _ = incomplete.Region // Validation logic that would be in getClient if incomplete.AccessKey == "" || incomplete.SecretKey == "" { diff --git a/docs/GUIA_MIGRACAO_FORGEJO.md b/docs/GUIA_MIGRACAO_FORGEJO.md new file mode 100644 index 0000000..93a84a6 --- /dev/null +++ b/docs/GUIA_MIGRACAO_FORGEJO.md @@ -0,0 +1,97 @@ +# Guia de Migração: GitHub para Forgejo + +Este guia documenta o processo padrão para migrar seus projetos existentes e novos para o repositório Forgejo da Rede5. + +## 1. Autenticação (Passo Único) + +Como o servidor é privado, a melhor forma de autenticar é gerando um **Access Token**. + +1. Acesse o Forgejo: [https://forgejo-gru.rede5.com.br/](https://forgejo-gru.rede5.com.br/) +2. Clique no seu Avatar (topo direito) -> **Configurações**. +3. Vá em **Aplicações** (Applications). +4. Gere um novo token (ex: "migration-token") e **copie-o**. + +> 💡 **Dica:** O token substitui sua senha nas operações de Git. + +--- + +## 2. Migrando um Projeto Existente (GitHub -> Forgejo) + +Se você já tem o projeto no computador (clonado do GitHub): + +### Opção A: Manter os dois repositórios (GitHub e Forgejo) +Ideal para transição suave. Você mantém o `origin` (GitHub) e adiciona um novo (Forgejo). + +```powershell +# 1. Entre na pasta do projeto +cd c:\caminho\do\projeto + +# 2. Adicione o novo remote (usando o token para não pedir senha) +# Sintaxe: https://@forgejo-gru.rede5.com.br/rede5/.git +git remote add forgero https://@forgejo-gru.rede5.com.br/rede5/.git + +# Exemplo real (substitua ): +# git remote add forgero https://@forgejo-gru.rede5.com.br/rede5/gohorsejobs.git + +# 3. Envie o código +git push forgero main +# (Ou 'master', dependendo de como está sua branch principal) +``` + +### Opção B: Mudar totalmente para o Forgejo +Se não vai mais usar o GitHub. + +```powershell +# 1. Remova o vínculo com o GitHub (opcional, ou apenas renomeie) +git remote remove origin + +# 2. Adicione o Forgejo como 'origin' (padrão) +git remote add origin https://@forgejo-gru.rede5.com.br/rede5/.git + +# 3. Envie o código e defina o upstream padrão +git push -u origin main +``` + +--- + +## 3. Comandos Padrão do Dia a Dia + +Depois de configurado, o fluxo é o mesmo: + +| Ação | Comando | +| :--- | :--- | +| **Baixar atualizações** | `git pull forgero main` | +| **Enviar alterações** | `git push forgero main` | +| **Verificar remotes** | `git remote -v` | +| **Criar nova branch** | `git checkout -b nova-feature` | +| **Enviar nova branch** | `git push forgero nova-feature` | + +--- + +## 4. Solução de Problemas Comuns + +### Erro: `fatal: Authentication failed` +* **Causa:** O Git não conseguiu logar. +* **Solução:** Verifique se o token no comando `git remote add` está correto ou se expirou. +* **Correção:** Atualize a URL com o token correto: + ```powershell + git remote set-url forgero https://@forgejo-gru.rede5.com.br/rede5/.git + ``` + +### Erro: `remote origin already exists` +* **Causa:** Você tentou adicionar um remote (`origin`) que já existe. +* **Solução:** Use outro nome (ex: `forgero`) ou mude a URL do existente. + ```powershell + # Adicionar com outro nome + git remote add forgero + + # OU Alterar o existente + git remote set-url origin + ``` + +### Erro: `refusing to merge unrelated histories` +* **Causa:** Você criou o repositório no Forgejo com README/Licença e tentou subir um projeto local que já tinha esses arquivos. +* **Solução:** + ```powershell + git pull forgero main --allow-unrelated-histories + ``` diff --git a/frontend/src/app/dashboard/my-applications/page.tsx b/frontend/src/app/dashboard/my-applications/page.tsx new file mode 100644 index 0000000..b9309b9 --- /dev/null +++ b/frontend/src/app/dashboard/my-applications/page.tsx @@ -0,0 +1,179 @@ +"use client"; + +import { useEffect, useState } from "react"; +import Link from "next/link"; +import { format } from "date-fns"; +import { + Building2, + MapPin, + Calendar, + Clock, + CheckCircle2, + XCircle, + AlertCircle, + FileText, + ExternalLink +} from "lucide-react"; + +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Skeleton } from "@/components/ui/skeleton"; +import { applicationsApi } from "@/lib/api"; +import { useNotify } from "@/contexts/notification-context"; + +interface Application { + id: string; + jobId: string; + jobTitle: string; + companyName: string; + companyId: string; + status: string; + createdAt: string; + resumeUrl?: string; + message?: string; +} + +const statusConfig: Record = { + pending: { label: "Pending", color: "bg-yellow-100 text-yellow-800 border-yellow-200", icon: Clock }, + reviewed: { label: "Viewed", color: "bg-blue-100 text-blue-800 border-blue-200", icon: CheckCircle2 }, + shortlisted: { label: "Shortlisted", color: "bg-purple-100 text-purple-800 border-purple-200", icon: CheckCircle2 }, + hired: { label: "Hired", color: "bg-green-100 text-green-800 border-green-200", icon: CheckCircle2 }, + rejected: { label: "Rejected", color: "bg-red-100 text-red-800 border-red-200", icon: XCircle }, +}; + +export default function MyApplicationsPage() { + const [applications, setApplications] = useState([]); + const [loading, setLoading] = useState(true); + const notify = useNotify(); + + useEffect(() => { + async function fetchApplications() { + try { + const data = await applicationsApi.listMine(); + setApplications(data || []); + } catch (error) { + console.error("Failed to fetch applications:", error); + notify.error("Error", "Failed to load your applications."); + } finally { + setLoading(false); + } + } + + fetchApplications(); + }, [notify]); + + if (loading) { + return ( +
+

My Applications

+
+ {[1, 2, 3].map((i) => ( + + + + + + + + + + ))} +
+
+ ); + } + + return ( +
+
+
+

My Applications

+

+ Track the status of your job applications. +

+
+ +
+ + {applications.length === 0 ? ( + + +

No applications yet

+

You haven't applied to any jobs yet.

+ +
+
+ ) : ( +
+ {applications.map((app) => { + const status = statusConfig[app.status] || { + label: app.status, + color: "bg-gray-100 text-gray-800 border-gray-200", + icon: AlertCircle + }; + const StatusIcon = status.icon; + + return ( + + +
+
+ + + {app.jobTitle} + + +
+ + {app.companyName} +
+
+ + + {status.label} + +
+
+ +
+
+ + Applied on {format(new Date(app.createdAt), "MMM d, yyyy")} +
+
+ + {app.message && ( +
+ "{app.message.length > 100 ? app.message.substring(0, 100) + "..." : app.message}" +
+ )} + +
+ {app.resumeUrl && ( + + )} + +
+
+
+ ); + })} +
+ )} +
+ ); +} diff --git a/frontend/src/app/jobs/[id]/apply/page.tsx b/frontend/src/app/jobs/[id]/apply/page.tsx index ef240fc..f6a023f 100644 --- a/frontend/src/app/jobs/[id]/apply/page.tsx +++ b/frontend/src/app/jobs/[id]/apply/page.tsx @@ -43,15 +43,13 @@ import { Separator } from "@/components/ui/separator"; import { Navbar } from "@/components/navbar"; import { Footer } from "@/components/footer"; import { useNotify } from "@/contexts/notification-context"; -import { jobsApi, applicationsApi, type ApiJob } from "@/lib/api"; +import { jobsApi, applicationsApi, storageApi, type ApiJob } from "@/lib/api"; +import { formatPhone } from "@/lib/utils"; +import { useTranslation } from "@/lib/i18n"; +import { getCurrentUser } from "@/lib/auth"; + + -// Step definitions -const steps = [ - { id: 1, title: "Personal Details", icon: User }, - { id: 2, title: "Resume & Documents", icon: FileText }, - { id: 3, title: "Experience", icon: Briefcase }, - { id: 4, title: "Additional Questions", icon: MessageSquare }, -]; export const runtime = 'edge'; @@ -61,11 +59,22 @@ export default function JobApplicationPage({ }: { params: Promise<{ id: string }>; }) { + const { t } = useTranslation(); + + const steps = [ + { id: 1, title: t("application.steps.personal"), icon: User }, + { id: 2, title: t("application.steps.documents"), icon: FileText }, + { id: 3, title: t("application.steps.experience"), icon: Briefcase }, + { id: 4, title: t("application.steps.additional"), icon: MessageSquare }, + ]; + const { id } = use(params); const router = useRouter(); const notify = useNotify(); const [currentStep, setCurrentStep] = useState(1); const [isSubmitting, setIsSubmitting] = useState(false); + const [isSubmitted, setIsSubmitted] = useState(false); + const [isUploading, setIsUploading] = useState(false); const [job, setJob] = useState(null); const [loading, setLoading] = useState(true); @@ -77,8 +86,10 @@ export default function JobApplicationPage({ phone: "", linkedin: "", privacyAccepted: false, + // Step 2 - resume: null as File | null, + resumeUrl: "", + resumeName: "", coverLetter: "", portfolioUrl: "", // Step 3 @@ -90,41 +101,69 @@ export default function JobApplicationPage({ }); const handleInputChange = (field: string, value: any) => { + if (field === "phone") { + value = formatPhone(value); + } setFormData((prev) => ({ ...prev, [field]: value })); }; + const handleResumeUpload = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + + if (file.size > 5 * 1024 * 1024) { + notify.error(t("application.toasts.fileTooLarge.title"), t("application.toasts.fileTooLarge.desc")); + return; + } + + try { + setIsUploading(true); + const { publicUrl } = await storageApi.uploadFile(file, "resumes"); + setFormData(prev => ({ + ...prev, + resumeUrl: publicUrl, + resumeName: file.name + })); + notify.success(t("application.toasts.uploadComplete.title"), t("application.toasts.uploadComplete.desc")); + } catch (error) { + console.error("Upload error:", error); + notify.error(t("application.toasts.uploadFailed.title"), t("application.toasts.uploadFailed.desc")); + } finally { + setIsUploading(false); + } + }; + const validateStep = (step: number) => { switch (step) { case 1: if (!formData.fullName || !formData.email || !formData.phone) { - notify.error( - "Required fields", - "Please fill out all required fields." - ); + notify.error(t("application.toasts.requiredFields.title"), t("application.toasts.requiredFields.desc")); return false; } if (!formData.email.includes("@")) { - notify.error( - "Invalid email", - "Please enter a valid email address." - ); + notify.error(t("application.toasts.invalidEmail.title"), t("application.toasts.invalidEmail.desc")); + return false; + } + if (formData.phone.length < 14) { // (11) 91234-5678 is 15 chars, (11) 1234-5678 is 14 chars + notify.error(t("application.toasts.invalidPhone.title"), t("application.toasts.invalidPhone.desc")); return false; } if (!formData.privacyAccepted) { - notify.error( - "Privacy policy", - "You must accept the privacy policy to continue." - ); + notify.error(t("application.toasts.privacyPolicy.title"), t("application.toasts.privacyPolicy.desc")); return false; } return true; case 2: + if (!formData.resumeUrl) { + notify.error(t("application.toasts.resumeRequired.title"), t("application.toasts.resumeRequired.desc")); + return false; + } return true; case 3: if (!formData.salaryExpectation || !formData.hasExperience) { notify.error( - "Required fields", - "Please answer all questions." + t("application.toasts.questionsRequired.title"), + t("application.toasts.questionsRequired.desc") ); return false; } @@ -132,8 +171,8 @@ export default function JobApplicationPage({ case 4: if (!formData.whyUs || formData.availability.length === 0) { notify.error( - "Required fields", - "Please provide your reason and select at least one availability option." + t("application.toasts.reasonRequired.title"), + t("application.toasts.reasonRequired.desc") ); return false; } @@ -171,7 +210,7 @@ export default function JobApplicationPage({ } } catch (err) { console.error("Error fetching job:", err); - notify.error("Error", "Failed to load job details"); + notify.error(t("application.toasts.loadError.title"), t("application.toasts.loadError.desc")); } finally { setLoading(false); } @@ -179,6 +218,18 @@ export default function JobApplicationPage({ fetchJob(); }, [id, notify]); + // Auto-fill for logged in users + useEffect(() => { + const user = getCurrentUser(); + if (user) { + setFormData(prev => ({ + ...prev, + fullName: user.name || prev.fullName, + email: user.email || prev.email, + })); + } + }, []); + const handleSubmit = async () => { setIsSubmitting(true); try { @@ -188,25 +239,30 @@ export default function JobApplicationPage({ email: formData.email, phone: formData.phone, linkedin: formData.linkedin, - coverLetter: formData.coverLetter, - portfolioUrl: formData.portfolioUrl, - salaryExpectation: formData.salaryExpectation, - hasExperience: formData.hasExperience, - whyUs: formData.whyUs, - availability: formData.availability, + resumeUrl: formData.resumeUrl, + coverLetter: formData.coverLetter || undefined, + portfolioUrl: formData.portfolioUrl || undefined, + message: formData.whyUs, // Mapping Why Us to Message/Notes + documents: {}, // TODO: Extra docs + // salaryExpectation: formData.salaryExpectation, // These fields might need to go into Notes or structured JSON if backend doesn't support them specifically? + // hasExperience: formData.hasExperience, + // Backend seems to map "documents" as JSONMap. We can put extra info there? + // Or put in "message" concatenated. + // Let's assume the backend 'message' field is good for "whyUs" }); notify.success( - "Application submitted!", - `Good luck! Your application for ${job?.title || 'this position'} has been received.` + t("application.toasts.submitted.title"), + t("application.toasts.submitted.desc", { jobTitle: job?.title || 'this position' }) ); - router.push("/dashboard/my-applications"); + setIsSubmitted(true); + window.scrollTo(0, 0); } catch (error: any) { console.error("Submit error:", error); notify.error( - "Error submitting", - error.message || "Please try again later." + t("application.toasts.submitError.title"), + error.message || t("application.toasts.submitError.default") ); } finally { setIsSubmitting(false); @@ -215,8 +271,8 @@ export default function JobApplicationPage({ const handleSaveDraft = () => { notify.info( - "Draft saved", - "You can finish your application later." + t("application.toasts.draftSaved.title"), + t("application.toasts.draftSaved.desc") ); }; @@ -224,6 +280,79 @@ export default function JobApplicationPage({ if (!job) return null; + if (isSubmitted) { + const user = getCurrentUser(); + + return ( +
+ +
+
+ + +
+ +
+ +
+

{t("application.success.title")}

+

+ {t("application.success.message")} {job.title}. +

+
+ +
+ {user ? ( +
+

+ + {t("candidate.dashboard.applications.title")} +

+

+ Acompanhe o status desta e de outras candidaturas no seu painel. +

+ +
+ ) : ( +
+

+ + {t("application.success.ctaTitle")} +

+

+ {t("application.success.ctaDesc")} +

+ +
+ )} +
+ +
+ +
+
+
+
+
+
+
+ ); + } + return (
@@ -237,19 +366,19 @@ export default function JobApplicationPage({ className="inline-flex items-center text-sm text-muted-foreground hover:text-primary mb-4 transition-colors" > - Back to job details + {t("application.back")}

- Application: {job.title} + {t("application.title", { jobTitle: job.title })}

{job.companyName || 'Company'} • {job.location || 'Remote'}

- Estimated time: 5 min + {t("application.estimatedTime")}
@@ -258,7 +387,7 @@ export default function JobApplicationPage({
- Step {currentStep} of {steps.length}:{" "} + {t("application.progress.step", { current: currentStep, total: steps.length })}{" "} {steps[currentStep - 1].title} @@ -278,10 +407,10 @@ export default function JobApplicationPage({
); @@ -343,7 +472,7 @@ export default function JobApplicationPage({ {steps[currentStep - 1].title} - Fill in the information below to continue. + {t("application.form.description")} @@ -353,10 +482,10 @@ export default function JobApplicationPage({
- + handleInputChange("fullName", e.target.value) @@ -364,11 +493,11 @@ export default function JobApplicationPage({ />
- + handleInputChange("email", e.target.value) @@ -379,10 +508,10 @@ export default function JobApplicationPage({
- + handleInputChange("phone", e.target.value) @@ -390,10 +519,10 @@ export default function JobApplicationPage({ />
- + handleInputChange("linkedin", e.target.value) @@ -414,11 +543,11 @@ export default function JobApplicationPage({ htmlFor="privacy" className="text-sm font-normal text-muted-foreground" > - I have read and agree to the{" "} + {t("application.form.privacy.agree")}{" "} - Privacy Policy + {t("application.form.privacy.policy")} {" "} - and authorize the processing of my data for recruitment purposes. + {t("application.form.privacy.authorize")}
@@ -428,18 +557,25 @@ export default function JobApplicationPage({ {currentStep === 2 && (
- -
+ +
+
- + {isUploading ? : }

- Click to upload or drag the file here + {formData.resumeName || t("application.form.upload.click")}

- PDF, DOCX, or TXT (Max 5MB) + {formData.resumeName ? t("application.form.upload.change") : t("application.form.upload.formats")}

@@ -448,11 +584,11 @@ export default function JobApplicationPage({
handleInputChange("portfolioUrl", e.target.value) @@ -462,11 +598,11 @@ export default function JobApplicationPage({