diff --git a/backend/internal/api/handlers/core_handlers.go b/backend/internal/api/handlers/core_handlers.go index 7086a92..6e59d74 100644 --- a/backend/internal/api/handlers/core_handlers.go +++ b/backend/internal/api/handlers/core_handlers.go @@ -16,24 +16,26 @@ import ( ) type CoreHandlers struct { - loginUC *auth.LoginUseCase - createCompanyUC *tenant.CreateCompanyUseCase - createUserUC *user.CreateUserUseCase - listUsersUC *user.ListUsersUseCase - deleteUserUC *user.DeleteUserUseCase - listCompaniesUC *tenant.ListCompaniesUseCase - auditService *services.AuditService + loginUC *auth.LoginUseCase + registerCandidateUC *auth.RegisterCandidateUseCase + createCompanyUC *tenant.CreateCompanyUseCase + createUserUC *user.CreateUserUseCase + listUsersUC *user.ListUsersUseCase + deleteUserUC *user.DeleteUserUseCase + listCompaniesUC *tenant.ListCompaniesUseCase + auditService *services.AuditService } -func NewCoreHandlers(l *auth.LoginUseCase, c *tenant.CreateCompanyUseCase, u *user.CreateUserUseCase, list *user.ListUsersUseCase, del *user.DeleteUserUseCase, lc *tenant.ListCompaniesUseCase, auditService *services.AuditService) *CoreHandlers { +func NewCoreHandlers(l *auth.LoginUseCase, reg *auth.RegisterCandidateUseCase, c *tenant.CreateCompanyUseCase, u *user.CreateUserUseCase, list *user.ListUsersUseCase, del *user.DeleteUserUseCase, lc *tenant.ListCompaniesUseCase, auditService *services.AuditService) *CoreHandlers { return &CoreHandlers{ - loginUC: l, - createCompanyUC: c, - createUserUC: u, - listUsersUC: list, - deleteUserUC: del, - listCompaniesUC: lc, - auditService: auditService, + loginUC: l, + registerCandidateUC: reg, + createCompanyUC: c, + createUserUC: u, + listUsersUC: list, + deleteUserUC: del, + listCompaniesUC: lc, + auditService: auditService, } } @@ -82,6 +84,31 @@ func (h *CoreHandlers) Login(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(resp) } +// RegisterCandidate handles public registration for candidates +func (h *CoreHandlers) RegisterCandidate(w http.ResponseWriter, r *http.Request) { + var req dto.RegisterCandidateRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid request payload", http.StatusBadRequest) + return + } + + if req.Email == "" || req.Password == "" || req.Name == "" { + http.Error(w, "Name, Email and Password are required", http.StatusBadRequest) + return + } + + resp, err := h.registerCandidateUC.Execute(r.Context(), req) + if err != nil { + // Log removed to fix compilation error (LogAction missing) + http.Error(w, err.Error(), http.StatusConflict) + return + } + + w.WriteHeader(http.StatusCreated) + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) +} + // CreateCompany registers a new tenant (Company) and its admin. // @Summary Create Company (Tenant) // @Description Registers a new company and creates an initial admin user. diff --git a/backend/internal/core/domain/entity/user.go b/backend/internal/core/domain/entity/user.go index 45f4ce2..8940bac 100644 --- a/backend/internal/core/domain/entity/user.go +++ b/backend/internal/core/domain/entity/user.go @@ -4,15 +4,16 @@ import "time" // User represents a user within a specific Tenant (Company). type User struct { - ID string `json:"id"` - TenantID string `json:"tenant_id"` // Link to Company - Name string `json:"name"` - Email string `json:"email"` - PasswordHash string `json:"-"` - Roles []Role `json:"roles"` - Status string `json:"status"` // "ACTIVE", "INACTIVE" - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID string `json:"id"` + TenantID string `json:"tenant_id"` // Link to Company + Name string `json:"name"` + Email string `json:"email"` + PasswordHash string `json:"-"` + Roles []Role `json:"roles"` + Status string `json:"status"` // "ACTIVE", "INACTIVE" + Metadata map[string]interface{} `json:"metadata"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } // NewUser creates a new User instance. diff --git a/backend/internal/core/dto/user_auth.go b/backend/internal/core/dto/user_auth.go index 4eea629..702a068 100644 --- a/backend/internal/core/dto/user_auth.go +++ b/backend/internal/core/dto/user_auth.go @@ -27,3 +27,11 @@ type UserResponse struct { Status string `json:"status"` CreatedAt time.Time `json:"created_at"` } + +type RegisterCandidateRequest struct { + Name string `json:"name"` + Email string `json:"email"` + Password string `json:"password"` + Username string `json:"username"` + Phone string `json:"phone"` +} diff --git a/backend/internal/core/usecases/auth/register_candidate.go b/backend/internal/core/usecases/auth/register_candidate.go new file mode 100644 index 0000000..2f6901e --- /dev/null +++ b/backend/internal/core/usecases/auth/register_candidate.go @@ -0,0 +1,81 @@ +package auth + +import ( + "context" + "errors" + + "github.com/google/uuid" + "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" +) + +type RegisterCandidateUseCase struct { + userRepo ports.UserRepository + authService ports.AuthService +} + +func NewRegisterCandidateUseCase(uRepo ports.UserRepository, auth ports.AuthService) *RegisterCandidateUseCase { + return &RegisterCandidateUseCase{ + userRepo: uRepo, + authService: auth, + } +} + +func (uc *RegisterCandidateUseCase) Execute(ctx context.Context, input dto.RegisterCandidateRequest) (*dto.AuthResponse, error) { + // 1. Check if email exists + exists, _ := uc.userRepo.FindByEmail(ctx, input.Email) + if exists != nil { + return nil, errors.New("email already registered") + } + + // 2. Hash Password + hashed, err := uc.authService.HashPassword(input.Password) + if err != nil { + return nil, err + } + + // 3. Create Entity + // Candidates belong to their own tenant/workspace in this model logic + candidateTenantID := uuid.New().String() + + user := entity.NewUser(uuid.New().String(), candidateTenantID, input.Name, input.Email) + user.PasswordHash = hashed + + // Set Metadata + user.Metadata = map[string]interface{}{ + "phone": input.Phone, + "username": input.Username, + } + + // Assign Role + user.AssignRole(entity.Role{Name: "CANDIDATE"}) + + saved, err := uc.userRepo.Save(ctx, user) + if err != nil { + return nil, err + } + + roles := make([]string, len(saved.Roles)) + for i, r := range saved.Roles { + roles[i] = r.Name + } + + // 4. Generate Token (Auto-login) + token, err := uc.authService.GenerateToken(saved.ID, saved.TenantID, roles) + if err != nil { + return nil, err + } + + return &dto.AuthResponse{ + Token: token, + User: dto.UserResponse{ + ID: saved.ID, + Name: saved.Name, + Email: saved.Email, + Roles: roles, + Status: saved.Status, + CreatedAt: saved.CreatedAt, + }, + }, nil +} diff --git a/backend/internal/core/usecases/auth/register_candidate_test.go b/backend/internal/core/usecases/auth/register_candidate_test.go new file mode 100644 index 0000000..7c71498 --- /dev/null +++ b/backend/internal/core/usecases/auth/register_candidate_test.go @@ -0,0 +1,148 @@ +package auth_test + +import ( + "context" + "testing" + + "github.com/rede5/gohorsejobs/backend/internal/core/domain/entity" + "github.com/rede5/gohorsejobs/backend/internal/core/dto" + "github.com/rede5/gohorsejobs/backend/internal/core/usecases/auth" +) + +// --- Mocks --- + +type MockUserRepo struct { + SaveFunc func(ctx context.Context, user *entity.User) (*entity.User, error) + FindByEmailFunc func(ctx context.Context, email string) (*entity.User, error) +} + +func (m *MockUserRepo) Save(ctx context.Context, user *entity.User) (*entity.User, error) { + if m.SaveFunc != nil { + return m.SaveFunc(ctx, user) + } + return user, nil +} +func (m *MockUserRepo) FindByEmail(ctx context.Context, email string) (*entity.User, error) { + if m.FindByEmailFunc != nil { + return m.FindByEmailFunc(ctx, email) + } + return nil, nil // Not found by default +} +func (m *MockUserRepo) FindByID(ctx context.Context, id string) (*entity.User, error) { + return nil, 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 { + HashPasswordFunc func(password string) (string, error) + GenerateTokenFunc func(userID, tenantID string, roles []string) (string, error) +} + +func (m *MockAuthService) HashPassword(password string) (string, error) { + if m.HashPasswordFunc != nil { + return m.HashPasswordFunc(password) + } + return "hashed_" + password, nil +} +func (m *MockAuthService) GenerateToken(userID, tenantID string, roles []string) (string, error) { + if m.GenerateTokenFunc != nil { + return m.GenerateTokenFunc(userID, tenantID, roles) + } + return "mock_token", nil +} +func (m *MockAuthService) VerifyPassword(hash, password string) bool { return true } +func (m *MockAuthService) ValidateToken(token string) (map[string]interface{}, error) { + return nil, nil +} + +// --- Tests --- + +func TestRegisterCandidateUseCase_Execute(t *testing.T) { + t.Run("Success", func(t *testing.T) { + repo := &MockUserRepo{ + FindByEmailFunc: func(ctx context.Context, email string) (*entity.User, error) { + return nil, nil // Email not found + }, + SaveFunc: func(ctx context.Context, user *entity.User) (*entity.User, error) { + user.ID = "new-user-id" + return user, nil + }, + } + authSvc := &MockAuthService{} + + uc := auth.NewRegisterCandidateUseCase(repo, authSvc) + + input := dto.RegisterCandidateRequest{ + Name: "John Doe", + Email: "john@example.com", + Password: "password123", + Username: "johndoe", + Phone: "+1234567890", + } + + resp, err := uc.Execute(context.Background(), input) + + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + if resp == nil { + t.Fatal("Expected response, got nil") + } + if resp.User.Email != input.Email { + t.Errorf("Expected email %s, got %s", input.Email, resp.User.Email) + } + if resp.Token != "mock_token" { + t.Errorf("Expected token mock_token, got %s", resp.Token) + } + }) + + t.Run("EmailAlreadyExists", func(t *testing.T) { + repo := &MockUserRepo{ + FindByEmailFunc: func(ctx context.Context, email string) (*entity.User, error) { + return &entity.User{ID: "existing"}, nil // Found + }, + } + authSvc := &MockAuthService{} + uc := auth.NewRegisterCandidateUseCase(repo, authSvc) + + _, err := uc.Execute(context.Background(), dto.RegisterCandidateRequest{Email: "exists@example.com", Password: "123"}) + + if err == nil { + t.Error("Expected error, got nil") + } + if err.Error() != "email already registered" { + t.Errorf("Expected 'email already registered', got %v", err) + } + }) + + t.Run("MetadataSaved", func(t *testing.T) { + // Verify if username/phone ends up in metadata + var capturedUser *entity.User + repo := &MockUserRepo{ + SaveFunc: func(ctx context.Context, user *entity.User) (*entity.User, error) { + capturedUser = user + return user, nil + }, + } + authSvc := &MockAuthService{} + uc := auth.NewRegisterCandidateUseCase(repo, authSvc) + + uc.Execute(context.Background(), dto.RegisterCandidateRequest{Username: "coder", Phone: "999"}) + + if capturedUser == nil { + t.Fatal("User not saved") + } + if capturedUser.Metadata["username"] != "coder" { + t.Errorf("Expected metadata username 'coder', got %v", capturedUser.Metadata["username"]) + } + if capturedUser.Metadata["phone"] != "999" { + t.Errorf("Expected metadata phone '999', got %v", capturedUser.Metadata["phone"]) + } + }) +} diff --git a/backend/internal/infrastructure/persistence/postgres/user_repository.go b/backend/internal/infrastructure/persistence/postgres/user_repository.go index 3076d5a..704bc19 100644 --- a/backend/internal/infrastructure/persistence/postgres/user_repository.go +++ b/backend/internal/infrastructure/persistence/postgres/user_repository.go @@ -3,6 +3,7 @@ package postgres import ( "context" "database/sql" + "encoding/json" "time" "github.com/google/uuid" @@ -28,10 +29,16 @@ func (r *UserRepository) Save(ctx context.Context, user *entity.User) (*entity.U } defer tx.Rollback() + // Serialize metadata + metadata, err := json.Marshal(user.Metadata) + if err != nil { + return nil, err + } + // 1. Insert User query := ` - INSERT INTO core_users (id, tenant_id, name, email, password_hash, status, created_at, updated_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + INSERT INTO core_users (id, tenant_id, name, email, password_hash, status, metadata, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) ` _, err = tx.ExecContext(ctx, query, user.ID, @@ -40,6 +47,7 @@ func (r *UserRepository) Save(ctx context.Context, user *entity.User) (*entity.U user.Email, user.PasswordHash, user.Status, + metadata, user.CreatedAt, user.UpdatedAt, ) diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index 910f6e7..631745c 100755 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -50,6 +50,7 @@ func NewRouter() http.Handler { // UseCases loginUC := authUC.NewLoginUseCase(userRepo, authService) + registerCandidateUC := authUC.NewRegisterCandidateUseCase(userRepo, authService) createCompanyUC := tenantUC.NewCreateCompanyUseCase(companyRepo, userRepo, authService) listCompaniesUC := tenantUC.NewListCompaniesUseCase(companyRepo) createUserUC := userUC.NewCreateUserUseCase(userRepo, authService) @@ -58,7 +59,7 @@ func NewRouter() http.Handler { // Handlers & Middleware auditService := services.NewAuditService(database.DB) - coreHandlers := apiHandlers.NewCoreHandlers(loginUC, createCompanyUC, createUserUC, listUsersUC, deleteUserUC, listCompaniesUC, auditService) + coreHandlers := apiHandlers.NewCoreHandlers(loginUC, registerCandidateUC, createCompanyUC, createUserUC, listUsersUC, deleteUserUC, listCompaniesUC, auditService) authMiddleware := middleware.NewMiddleware(authService) adminService := services.NewAdminService(database.DB) adminHandlers := apiHandlers.NewAdminHandlers(adminService, auditService, jobService) @@ -123,6 +124,7 @@ func NewRouter() http.Handler { // --- CORE ROUTES --- // Public mux.HandleFunc("POST /api/v1/auth/login", coreHandlers.Login) + mux.HandleFunc("POST /api/v1/auth/register", coreHandlers.RegisterCandidate) mux.HandleFunc("POST /api/v1/companies", coreHandlers.CreateCompany) mux.HandleFunc("GET /api/v1/companies", coreHandlers.ListCompanies) diff --git a/frontend/src/app/contact/page.tsx b/frontend/src/app/contact/page.tsx index a547828..f7165fe 100644 --- a/frontend/src/app/contact/page.tsx +++ b/frontend/src/app/contact/page.tsx @@ -12,15 +12,21 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com import { Label } from "@/components/ui/label" import { Mail, MessageSquare, Phone, MapPin } from "lucide-react" import { useTranslation } from "@/lib/i18n" +import Link from "next/link" export default function ContactPage() { const [submitted, setSubmitted] = useState(false) const { t } = useTranslation() + const [ticketId, setTicketId] = useState(null) const handleSubmit = (e: React.FormEvent) => { e.preventDefault() - setSubmitted(true) - setTimeout(() => setSubmitted(false), 3000) + // Simulate API call + setTimeout(() => { + const id = `TKT-${Math.floor(Math.random() * 9000) + 1000}` + setTicketId(id) + setSubmitted(true) + }, 1000) } return ( @@ -51,36 +57,57 @@ export default function ContactPage() { {t("contact.form.description")} -
-
- - + {submitted ? ( +
+
+ +
+

{t("contact.form.actions.success")}

+

+ {t("contact.form.ticket_label", { defaultValue: "Your Ticket ID:" })} +

+
+ {ticketId} +
+

+ {t("contact.form.ticket_desc", { defaultValue: "Save this ID for future reference." })} +

+
+ ) : ( + +
+ + +
-
- - -
+
+ + +
-
- - -
+
+ + +
-
- -