diff --git a/backend/.gitignore b/backend/.gitignore index ecf545a..cbb042e 100755 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -21,4 +21,4 @@ go.work .env # Compiled binary -api +/api diff --git a/backend/internal/api/handlers/location_handlers.go b/backend/internal/api/handlers/location_handlers.go new file mode 100644 index 0000000..7c836fd --- /dev/null +++ b/backend/internal/api/handlers/location_handlers.go @@ -0,0 +1,113 @@ +package handlers + +import ( + "encoding/json" + "net/http" + "strconv" + + "github.com/rede5/gohorsejobs/backend/internal/services" +) + +type LocationHandlers struct { + service *services.LocationService +} + +func NewLocationHandlers(service *services.LocationService) *LocationHandlers { + return &LocationHandlers{service: service} +} + +// ListCountries returns all countries +func (h *LocationHandlers) ListCountries(w http.ResponseWriter, r *http.Request) { + countries, err := h.service.ListCountries(r.Context()) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(countries) +} + +// ListStatesByCountry returns states for a given country ID +// Expects path param {id} +func (h *LocationHandlers) ListStatesByCountry(w http.ResponseWriter, r *http.Request) { + idStr := r.PathValue("id") + if idStr == "" { + http.Error(w, "Country ID is required", http.StatusBadRequest) + return + } + + countryID, err := strconv.ParseInt(idStr, 10, 64) + if err != nil { + http.Error(w, "Invalid Country ID", http.StatusBadRequest) + return + } + + states, err := h.service.ListStates(r.Context(), countryID) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(states) +} + +// ListCitiesByState returns cities for a given state ID +// Expects path param {id} +func (h *LocationHandlers) ListCitiesByState(w http.ResponseWriter, r *http.Request) { + idStr := r.PathValue("id") + if idStr == "" { + http.Error(w, "State ID is required", http.StatusBadRequest) + return + } + + stateID, err := strconv.ParseInt(idStr, 10, 64) + if err != nil { + http.Error(w, "Invalid State ID", http.StatusBadRequest) + return + } + + cities, err := h.service.ListCities(r.Context(), stateID) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(cities) +} + +// SearchLocations searches for cities or states in a country +// Query params: q (query), country_id (required) +func (h *LocationHandlers) SearchLocations(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query().Get("q") + countryIDStr := r.URL.Query().Get("country_id") + + if q == "" || len(q) < 2 { + // Return empty list if query too short to avoid massive scan (though DB handles it, better UX) + w.Header().Set("Content-Type", "application/json") + w.Write([]byte("[]")) + return + } + + if countryIDStr == "" { + http.Error(w, "country_id is required", http.StatusBadRequest) + return + } + + countryID, err := strconv.ParseInt(countryIDStr, 10, 64) + if err != nil { + http.Error(w, "Invalid country_id", http.StatusBadRequest) + return + } + + results, err := h.service.Search(r.Context(), q, countryID) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(results) +} diff --git a/backend/internal/core/domain/entity/location.go b/backend/internal/core/domain/entity/location.go new file mode 100644 index 0000000..7870834 --- /dev/null +++ b/backend/internal/core/domain/entity/location.go @@ -0,0 +1,45 @@ +package entity + +import "time" + +type Country struct { + ID int64 `json:"id" db:"id"` + Name string `json:"name" db:"name"` + ISO2 string `json:"iso2" db:"iso2"` + ISO3 string `json:"iso3" db:"iso3"` + PhoneCode string `json:"phone_code" db:"phonecode"` + Currency string `json:"currency" db:"currency"` + Emoji string `json:"emoji" db:"emoji"` + EmojiU string `json:"emojiU" db:"emoji_u"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` +} + +type State struct { + ID int64 `json:"id" db:"id"` + Name string `json:"name" db:"name"` + CountryID int64 `json:"country_id" db:"country_id"` + CountryCode string `json:"country_code" db:"country_code"` + ISO2 string `json:"iso2" db:"iso2"` + Type string `json:"type" db:"type"` // State, Province, etc. + Latitude float64 `json:"latitude" db:"latitude"` + Longitude float64 `json:"longitude" db:"longitude"` +} + +type City struct { + ID int64 `json:"id" db:"id"` + Name string `json:"name" db:"name"` + StateID int64 `json:"state_id" db:"state_id"` + CountryID int64 `json:"country_id" db:"country_id"` + Latitude float64 `json:"latitude" db:"latitude"` + Longitude float64 `json:"longitude" db:"longitude"` +} + +type LocationSearchResult struct { + ID int64 `json:"id"` + Name string `json:"name"` + Type string `json:"type"` // "state" or "city" + CountryID int64 `json:"country_id"` + StateID *int64 `json:"state_id,omitempty"` + RegionName string `json:"region_name,omitempty"` // e.g. State name if city +} diff --git a/backend/internal/infrastructure/persistence/postgres/location_repository.go b/backend/internal/infrastructure/persistence/postgres/location_repository.go new file mode 100644 index 0000000..ff9a0ce --- /dev/null +++ b/backend/internal/infrastructure/persistence/postgres/location_repository.go @@ -0,0 +1,144 @@ +package postgres + +import ( + "context" + "database/sql" + + "github.com/rede5/gohorsejobs/backend/internal/core/domain/entity" +) + +type LocationRepository struct { + db *sql.DB +} + +func NewLocationRepository(db *sql.DB) *LocationRepository { + return &LocationRepository{db: db} +} + +func (r *LocationRepository) ListCountries(ctx context.Context) ([]*entity.Country, error) { + query := `SELECT id, name, iso2, iso3, phonecode, currency, emoji, emoji_u, created_at, updated_at FROM countries ORDER BY name ASC` + rows, err := r.db.QueryContext(ctx, query) + if err != nil { + return nil, err + } + defer rows.Close() + + var countries []*entity.Country + for rows.Next() { + c := &entity.Country{} + // Scan columns. Be careful with NULL handling if fields are nullable, but schema says NOT NULL mostly. + // Check schema again. phonecode, etc can be null. + // For simplicity, we scan into sql.NullString or just string (and risk error if NULL). + // Migration 021 says: + // name VARCHAR(100) NOT NULL + // iso2 CHAR(2) + // phonecode VARCHAR(255) + // ... + // So nullable fields need handling. + + var iso2, iso3, phonecode, currency, emoji, emojiU sql.NullString + + if err := rows.Scan(&c.ID, &c.Name, &iso2, &iso3, &phonecode, ¤cy, &emoji, &emojiU, &c.CreatedAt, &c.UpdatedAt); err != nil { + return nil, err + } + c.ISO2 = iso2.String + c.ISO3 = iso3.String + c.PhoneCode = phonecode.String + c.Currency = currency.String + c.Emoji = emoji.String + c.EmojiU = emojiU.String + + countries = append(countries, c) + } + return countries, nil +} + +func (r *LocationRepository) ListStates(ctx context.Context, countryID int64) ([]*entity.State, error) { + query := `SELECT id, name, country_id, country_code, iso2, type, latitude, longitude FROM states WHERE country_id = $1 ORDER BY name ASC` + rows, err := r.db.QueryContext(ctx, query, countryID) + if err != nil { + return nil, err + } + defer rows.Close() + + var states []*entity.State + for rows.Next() { + s := &entity.State{} + var iso2, typeStr sql.NullString + var lat, lng sql.NullFloat64 + + if err := rows.Scan(&s.ID, &s.Name, &s.CountryID, &s.CountryCode, &iso2, &typeStr, &lat, &lng); err != nil { + return nil, err + } + s.ISO2 = iso2.String + s.Type = typeStr.String + s.Latitude = lat.Float64 + s.Longitude = lng.Float64 + + states = append(states, s) + } + return states, nil +} + +func (r *LocationRepository) ListCities(ctx context.Context, stateID int64) ([]*entity.City, error) { + query := `SELECT id, name, state_id, country_id, latitude, longitude FROM cities WHERE state_id = $1 ORDER BY name ASC` + rows, err := r.db.QueryContext(ctx, query, stateID) + if err != nil { + return nil, err + } + defer rows.Close() + + var cities []*entity.City + for rows.Next() { + c := &entity.City{} + // schema: latitude NOT NULL, longitude NOT NULL. + if err := rows.Scan(&c.ID, &c.Name, &c.StateID, &c.CountryID, &c.Latitude, &c.Longitude); err != nil { + return nil, err + } + cities = append(cities, c) + } + return cities, nil +} + +func (r *LocationRepository) Search(ctx context.Context, query string, countryID int64) ([]*entity.LocationSearchResult, error) { + // Search Cities and States + // Simple LIKE query + q := "%" + query + "%" + + sqlQuery := ` + SELECT id, name, 'state' as type, country_id, NULL as state_id, '' as region_name + FROM states + WHERE country_id = $1 AND name ILIKE $2 + UNION ALL + SELECT c.id, c.name, 'city' as type, c.country_id, c.state_id, s.name as region_name + FROM cities c + JOIN states s ON c.state_id = s.id + WHERE c.country_id = $1 AND c.name ILIKE $2 + LIMIT 20 + ` + + rows, err := r.db.QueryContext(ctx, sqlQuery, countryID, q) + if err != nil { + return nil, err + } + defer rows.Close() + + var results []*entity.LocationSearchResult + for rows.Next() { + res := &entity.LocationSearchResult{} + var stateID sql.NullInt64 + var regionName sql.NullString + + if err := rows.Scan(&res.ID, &res.Name, &res.Type, &res.CountryID, &stateID, ®ionName); err != nil { + return nil, err + } + + if stateID.Valid { + id := stateID.Int64 + res.StateID = &id + } + res.RegionName = regionName.String + results = append(results, res) + } + return results, nil +} diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index 05044bc..35e9bf3 100755 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -35,8 +35,10 @@ func NewRouter() http.Handler { // --- CORE ARCHITECTURE INITIALIZATION --- // Infrastructure + // Infrastructure userRepo := postgres.NewUserRepository(database.DB) companyRepo := postgres.NewCompanyRepository(database.DB) + locationRepo := postgres.NewLocationRepository(database.DB) // Utils Services (Moved up for dependency injection) credentialsService := services.NewCredentialsService(database.DB) @@ -45,6 +47,7 @@ func NewRouter() http.Handler { fcmService := services.NewFCMService(credentialsService) cloudflareService := services.NewCloudflareService(credentialsService) emailService := services.NewEmailService(database.DB, credentialsService) + locationService := services.NewLocationService(locationRepo) adminService := services.NewAdminService(database.DB) @@ -94,6 +97,7 @@ func NewRouter() http.Handler { settingsHandler := apiHandlers.NewSettingsHandler(settingsService) storageHandler := apiHandlers.NewStorageHandler(storageService) adminHandlers := apiHandlers.NewAdminHandlers(adminService, auditService, jobService, cloudflareService) + locationHandlers := apiHandlers.NewLocationHandlers(locationService) // Initialize Legacy Handlers jobHandler := handlers.NewJobHandler(jobService) @@ -210,6 +214,12 @@ func NewRouter() http.Handler { // Get Company by ID (Public) mux.HandleFunc("GET /api/v1/companies/{id}", coreHandlers.GetCompanyByID) + // Location Routes (Public) + mux.HandleFunc("GET /api/v1/locations/countries", locationHandlers.ListCountries) + mux.HandleFunc("GET /api/v1/locations/countries/{id}/states", locationHandlers.ListStatesByCountry) + mux.HandleFunc("GET /api/v1/locations/states/{id}/cities", locationHandlers.ListCitiesByState) + mux.HandleFunc("GET /api/v1/locations/search", locationHandlers.SearchLocations) + // Notifications Route mux.Handle("GET /api/v1/notifications", authMiddleware.HeaderAuthGuard(http.HandlerFunc(coreHandlers.ListNotifications))) mux.Handle("POST /api/v1/tokens", authMiddleware.HeaderAuthGuard(http.HandlerFunc(coreHandlers.SaveFCMToken))) diff --git a/backend/internal/services/location_service.go b/backend/internal/services/location_service.go new file mode 100644 index 0000000..7bc5721 --- /dev/null +++ b/backend/internal/services/location_service.go @@ -0,0 +1,32 @@ +package services + +import ( + "context" + + "github.com/rede5/gohorsejobs/backend/internal/core/domain/entity" + "github.com/rede5/gohorsejobs/backend/internal/infrastructure/persistence/postgres" +) + +type LocationService struct { + repo *postgres.LocationRepository +} + +func NewLocationService(repo *postgres.LocationRepository) *LocationService { + return &LocationService{repo: repo} +} + +func (s *LocationService) ListCountries(ctx context.Context) ([]*entity.Country, error) { + return s.repo.ListCountries(ctx) +} + +func (s *LocationService) ListStates(ctx context.Context, countryID int64) ([]*entity.State, error) { + return s.repo.ListStates(ctx, countryID) +} + +func (s *LocationService) ListCities(ctx context.Context, stateID int64) ([]*entity.City, error) { + return s.repo.ListCities(ctx, stateID) +} + +func (s *LocationService) Search(ctx context.Context, query string, countryID int64) ([]*entity.LocationSearchResult, error) { + return s.repo.Search(ctx, query, countryID) +} diff --git a/backend/migrations/029_expand_employment_types.sql b/backend/migrations/029_expand_employment_types.sql new file mode 100644 index 0000000..3a96597 --- /dev/null +++ b/backend/migrations/029_expand_employment_types.sql @@ -0,0 +1,19 @@ +-- Migration: Update employment_type constraint with additional values +-- Description: Add permanent, training, temporary, voluntary to employment_type + +-- Drop existing constraint and recreate with expanded values +ALTER TABLE jobs DROP CONSTRAINT IF EXISTS jobs_employment_type_check; + +ALTER TABLE jobs ADD CONSTRAINT jobs_employment_type_check + CHECK (employment_type IN ( + 'full-time', + 'part-time', + 'dispatch', + 'contract', + 'permanent', + 'training', + 'temporary', + 'voluntary' + )); + +COMMENT ON COLUMN jobs.employment_type IS 'Employment type: full-time, part-time, dispatch, contract, permanent, training, temporary, voluntary'; diff --git a/frontend/src/app/post-job/page.tsx b/frontend/src/app/post-job/page.tsx index 8a68dce..9ac624e 100644 --- a/frontend/src/app/post-job/page.tsx +++ b/frontend/src/app/post-job/page.tsx @@ -10,7 +10,32 @@ import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; import { Label } from "@/components/ui/label"; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"; -import { Building2, Briefcase, User, Mail, Lock, Phone, MapPin } from "lucide-react"; +import { + Building2, Briefcase, Mail, Lock, Phone, MapPin, + Eye, EyeOff, Globe +} from "lucide-react"; +import { LocationPicker } from "@/components/location-picker"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; + +// Common Country Codes +const COUNTRY_CODES = [ + { code: "+55", country: "Brasil (BR)" }, + { code: "+1", country: "Estados Unidos (US)" }, + { code: "+351", country: "Portugal (PT)" }, + { code: "+44", country: "Reino Unido (UK)" }, + { code: "+33", country: "França (FR)" }, + { code: "+49", country: "Alemanha (DE)" }, + { code: "+34", country: "Espanha (ES)" }, + { code: "+39", country: "Itália (IT)" }, + { code: "+81", country: "Japão (JP)" }, + { code: "+86", country: "China (CN)" }, + { code: "+91", country: "Índia (IN)" }, + { code: "+52", country: "México (MX)" }, + { code: "+54", country: "Argentina (AR)" }, + { code: "+57", country: "Colômbia (CO)" }, + { code: "+56", country: "Chile (CL)" }, + { code: "+51", country: "Peru (PE)" }, +].sort((a, b) => a.country.localeCompare(b.country)); export default function PostJobPage() { const router = useRouter(); @@ -22,9 +47,14 @@ export default function PostJobPage() { name: "", email: "", password: "", + confirmPassword: "", + ddi: "+55", phone: "", }); + const [showPassword, setShowPassword] = useState(false); + const [showConfirmPassword, setShowConfirmPassword] = useState(false); + // Job data const [job, setJob] = useState({ title: "", @@ -32,16 +62,35 @@ export default function PostJobPage() { location: "", salaryMin: "", salaryMax: "", - employmentType: "full-time", + employmentType: "", workMode: "remote", }); + const formatPhoneForDisplay = (value: string) => { + // Simple formatting to just allow numbers and basic separators if needed + // For now, just pass through but maybe restrict chars? + return value.replace(/[^\d\s-]/g, ""); + }; + const handleSubmit = async () => { if (!company.name || !company.email || !company.password) { - toast.error("Preencha os dados da empresa"); + toast.error("Preencha os dados obrigatórios da empresa"); setStep(1); return; } + + if (company.password !== company.confirmPassword) { + toast.error("As senhas não coincidem"); + setStep(1); + return; + } + + if (company.password.length < 8) { + toast.error("A senha deve ter pelo menos 8 caracteres"); + setStep(1); + return; + } + if (!job.title || !job.description) { toast.error("Preencha os dados da vaga"); setStep(2); @@ -52,6 +101,10 @@ export default function PostJobPage() { try { const apiBase = process.env.NEXT_PUBLIC_API_URL || ""; + // Format phone: DDI + Phone (digits only) + const cleanPhone = company.phone.replace(/\D/g, ""); + const finalPhone = cleanPhone ? `${company.ddi}${cleanPhone}` : ""; + // 1. Register Company (creates user + company) const registerRes = await fetch(`${apiBase}/api/v1/auth/register/company`, { method: "POST", @@ -60,7 +113,7 @@ export default function PostJobPage() { companyName: company.name, email: company.email, password: company.password, - phone: company.phone, + phone: finalPhone, }), }); @@ -181,31 +234,89 @@ export default function PostJobPage() { /> + + {/* Password Field */}
+ Selecione o código do país e digite o número com DDD. +
Nome: {company.name}
Email: {company.email}
- {company.phone &&Telefone: {company.phone}
} + {company.phone &&Telefone: {company.ddi} {company.phone}
}