diff --git a/backend/internal/api/handlers/core_handlers.go b/backend/internal/api/handlers/core_handlers.go index 474d7e3..c29630c 100644 --- a/backend/internal/api/handlers/core_handlers.go +++ b/backend/internal/api/handlers/core_handlers.go @@ -178,6 +178,10 @@ func (h *CoreHandlers) CreateCompany(w http.ResponseWriter, r *http.Request) { resp, err := h.createCompanyUC.Execute(r.Context(), req) if err != nil { + if strings.Contains(err.Error(), "already exists") { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } http.Error(w, err.Error(), http.StatusInternalServerError) return } diff --git a/backend/internal/core/domain/entity/company.go b/backend/internal/core/domain/entity/company.go index 8a8413b..089c609 100644 --- a/backend/internal/core/domain/entity/company.go +++ b/backend/internal/core/domain/entity/company.go @@ -4,13 +4,19 @@ import "time" // Company represents a Tenant in the system. type Company struct { - ID string `json:"id"` - Name string `json:"name"` - Document *string `json:"document,omitempty"` // CNPJ, EIN, VAT - Contact *string `json:"contact,omitempty"` - Status string `json:"status"` // "ACTIVE", "INACTIVE" - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID string `json:"id"` + Name string `json:"name"` + Slug string `json:"slug"` + Document *string `json:"document,omitempty"` // CNPJ, EIN, VAT + Contact *string `json:"contact,omitempty"` // Email + Phone *string `json:"phone,omitempty"` + Website *string `json:"website,omitempty"` + Address *string `json:"address,omitempty"` + Description *string `json:"description,omitempty"` + Type string `json:"type"` // "COMPANY", "CANDIDATE_WORKSPACE" + Status string `json:"status"` // "ACTIVE", "INACTIVE" + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } // NewCompany creates a new Company instance with defaults. @@ -18,6 +24,8 @@ func NewCompany(id, name string, document, contact *string) *Company { return &Company{ ID: id, Name: name, + Slug: name, // Basic slug, repo might refine + Type: "COMPANY", Document: document, Contact: contact, Status: "ACTIVE", diff --git a/backend/internal/core/dto/company.go b/backend/internal/core/dto/company.go index 3f48f4a..509a915 100644 --- a/backend/internal/core/dto/company.go +++ b/backend/internal/core/dto/company.go @@ -12,6 +12,7 @@ type CreateCompanyRequest struct { Password string `json:"password"` Phone string `json:"phone"` Website *string `json:"website,omitempty"` + Address *string `json:"address,omitempty"` EmployeeCount *string `json:"employeeCount,omitempty"` FoundedYear *int `json:"foundedYear,omitempty"` Description *string `json:"description,omitempty"` diff --git a/backend/internal/core/usecases/auth/register_candidate.go b/backend/internal/core/usecases/auth/register_candidate.go index f215d7d..bfc2b21 100644 --- a/backend/internal/core/usecases/auth/register_candidate.go +++ b/backend/internal/core/usecases/auth/register_candidate.go @@ -51,6 +51,7 @@ func (uc *RegisterCandidateUseCase) Execute(ctx context.Context, input dto.Regis nil, // No document for candidates nil, // No contact - will use user's contact info ) + candidateCompany.Type = "CANDIDATE_WORKSPACE" savedCompany, err := uc.companyRepo.Save(ctx, candidateCompany) if err != nil { diff --git a/backend/internal/core/usecases/tenant/create_company.go b/backend/internal/core/usecases/tenant/create_company.go index a2149df..2bbb0b1 100644 --- a/backend/internal/core/usecases/tenant/create_company.go +++ b/backend/internal/core/usecases/tenant/create_company.go @@ -2,6 +2,7 @@ package tenant import ( "context" + "fmt" "github.com/rede5/gohorsejobs/backend/internal/core/domain/entity" "github.com/rede5/gohorsejobs/backend/internal/core/dto" @@ -37,22 +38,57 @@ func (uc *CreateCompanyUseCase) Execute(ctx context.Context, input dto.CreateCom // I'll generate a random ID here for simulation if I had a uuid lib. // Since I want to be agnostic and dependency-free, I'll assume the Repo 'Save' returns the fully populated entity including ID. + // 0. Ensure AdminEmail is set (fallback to Email) + if input.AdminEmail == "" { + input.AdminEmail = input.Email + } + if input.Contact == "" && input.Email != "" { + input.Contact = input.Email + } + + // 1. Check if user already exists + existingUser, _ := uc.userRepo.FindByEmail(ctx, input.AdminEmail) + if existingUser != nil { + return nil, fmt.Errorf("user with email %s already exists", input.AdminEmail) + } + company := entity.NewCompany("", input.Name, &input.Document, &input.Contact) + // Map optional fields + if input.Phone != "" { + company.Phone = &input.Phone + } + if input.Website != nil { + company.Website = input.Website + } + if input.Description != nil { + company.Description = input.Description + } + if input.Address != nil { + company.Address = input.Address + } + // Address isn't in DTO explicitly but maybe part of inputs? + // Checking DTO: it has no Address field. + // I will check DTO again. Step 2497 showed Name, CompanyName, Document, Contact, AdminEmail, Email, Password, Phone, Website, EmployeeCount, FoundedYear, Description. + // It misses Address. + // I will skip Address mapping for now or add it to DTO if user wants it. + // But let's map what we have. + savedCompany, err := uc.companyRepo.Save(ctx, company) if err != nil { return nil, err } // 2. Create Admin User - // We need a password for the admin. Do we generate one? Or did input provide? - // input.AdminEmail is present. But no password. I'll generic default or ask to send email. - // For simplicity, let's assume a default password "ChangeMe123!" hash it. - hashedPassword, _ := uc.authService.HashPassword("ChangeMe123!") + pwd := input.Password + if pwd == "" { + pwd = "ChangeMe123!" + } + hashedPassword, _ := uc.authService.HashPassword(pwd) adminUser := entity.NewUser("", savedCompany.ID, "Admin", input.AdminEmail) adminUser.PasswordHash = hashedPassword - adminUser.AssignRole(entity.Role{Name: "ADMIN"}) + adminUser.AssignRole(entity.Role{Name: entity.RoleAdmin}) _, err = uc.userRepo.Save(ctx, adminUser) if err != nil { diff --git a/backend/internal/dto/auth.go b/backend/internal/dto/auth.go index 2108516..0ca4d32 100755 --- a/backend/internal/dto/auth.go +++ b/backend/internal/dto/auth.go @@ -70,4 +70,5 @@ type User struct { AvatarUrl *string `json:"avatarUrl,omitempty"` CreatedAt time.Time `json:"createdAt"` CompanyID *string `json:"companyId,omitempty"` + Roles []string `json:"roles,omitempty"` } diff --git a/backend/internal/infrastructure/persistence/postgres/company_repository.go b/backend/internal/infrastructure/persistence/postgres/company_repository.go index 8937142..aa7f363 100644 --- a/backend/internal/infrastructure/persistence/postgres/company_repository.go +++ b/backend/internal/infrastructure/persistence/postgres/company_repository.go @@ -20,21 +20,30 @@ func NewCompanyRepository(db *sql.DB) *CompanyRepository { func (r *CompanyRepository) Save(ctx context.Context, company *entity.Company) (*entity.Company, error) { // companies table uses UUID id, DB generates it query := ` - INSERT INTO companies (name, slug, type, document, email, description, verified, active, created_at, updated_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + INSERT INTO companies (name, slug, type, document, email, phone, website, address, description, verified, active, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) RETURNING id ` - slug := company.Name // TODO: slugify function + slug := company.Slug + // Fallback slug generation if empty + if slug == "" { + slug = company.Name + } + // TODO: better slugify logic in service/entity + var id string err := r.db.QueryRowContext(ctx, query, company.Name, slug, - "company", + company.Type, company.Document, - company.Contact, // mapped to email - "{}", // description as JSON - true, // verified + company.Contact, // email + company.Phone, + company.Website, + company.Address, + company.Description, + true, // verified company.Status == "ACTIVE", company.CreatedAt, company.UpdatedAt, diff --git a/backend/internal/services/admin_service.go b/backend/internal/services/admin_service.go index fb1f56d..be4545e 100644 --- a/backend/internal/services/admin_service.go +++ b/backend/internal/services/admin_service.go @@ -25,10 +25,11 @@ func (s *AdminService) ListCompanies(ctx context.Context, verified *bool, page, offset := (page - 1) * limit // Count Total - countQuery := `SELECT COUNT(*) FROM companies` + // Count Total + countQuery := `SELECT COUNT(*) FROM companies WHERE type != 'CANDIDATE_WORKSPACE'` var countArgs []interface{} if verified != nil { - countQuery += " WHERE verified = $1" + countQuery += " AND verified = $1" countArgs = append(countArgs, *verified) } var total int @@ -40,11 +41,12 @@ func (s *AdminService) ListCompanies(ctx context.Context, verified *bool, page, baseQuery := ` SELECT id, name, slug, type, document, address, region_id, city_id, phone, email, website, logo_url, description, active, verified, created_at, updated_at FROM companies + WHERE type != 'CANDIDATE_WORKSPACE' ` var args []interface{} if verified != nil { - baseQuery += " WHERE verified = $1" + baseQuery += " AND verified = $1" args = append(args, *verified) } @@ -610,9 +612,43 @@ func (s *AdminService) GetUser(ctx context.Context, id string) (*dto.User, error if avatarURL.Valid { u.AvatarUrl = &avatarURL.String } + + // Fetch roles + roles, err := s.getUserRoles(ctx, u.ID) + if err == nil { + u.Roles = roles + } + return &u, nil } +func (s *AdminService) getUserRoles(ctx context.Context, userID string) ([]string, error) { + query := ` + SELECT role FROM user_roles WHERE user_id = $1 + UNION + SELECT role FROM users WHERE id = $1 AND role IS NOT NULL AND role != '' + ` + rows, err := s.DB.QueryContext(ctx, query, userID) + if err != nil { + return nil, err + } + defer rows.Close() + + var roles []string + seen := make(map[string]bool) + + for rows.Next() { + var roleName string + if err := rows.Scan(&roleName); err == nil { + if !seen[roleName] { + roles = append(roles, roleName) + seen[roleName] = true + } + } + } + return roles, nil +} + // GetCompanyByUserID fetches the company associated with a user func (s *AdminService) GetCompanyByUserID(ctx context.Context, userID string) (*models.Company, error) { // First, try to find company where this user is admin @@ -728,9 +764,28 @@ func (s *AdminService) DeleteCompany(ctx context.Context, id string) error { return err } - // Delete - _, err = s.DB.ExecContext(ctx, `DELETE FROM companies WHERE id=$1`, id) - return err + tx, err := s.DB.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + + // Delete jobs + if _, err := tx.ExecContext(ctx, `DELETE FROM jobs WHERE company_id=$1`, id); err != nil { + return err + } + + // Delete users + if _, err := tx.ExecContext(ctx, `DELETE FROM users WHERE tenant_id=$1`, id); err != nil { + return err + } + + // Delete company + if _, err := tx.ExecContext(ctx, `DELETE FROM companies WHERE id=$1`, id); err != nil { + return err + } + + return tx.Commit() } // ============================================================================ diff --git a/backend/internal/services/job_service.go b/backend/internal/services/job_service.go index 1f83107..4256945 100644 --- a/backend/internal/services/job_service.go +++ b/backend/internal/services/job_service.go @@ -79,7 +79,10 @@ func (s *JobService) GetJobs(filter dto.JobFilterQuery) ([]models.JobWithCompany SELECT j.id, j.company_id, j.title, j.description, j.salary_min, j.salary_max, j.salary_type, j.employment_type, j.work_mode, j.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, + CASE + WHEN c.type = 'CANDIDATE_WORKSPACE' OR c.name LIKE 'Candidate - %' THEN '' + ELSE COALESCE(c.name, '') + END as company_name, c.logo_url as company_logo_url, 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 diff --git a/frontend/src/app/dashboard/companies/page.tsx b/frontend/src/app/dashboard/companies/page.tsx index 29c4042..2bc06af 100644 --- a/frontend/src/app/dashboard/companies/page.tsx +++ b/frontend/src/app/dashboard/companies/page.tsx @@ -4,6 +4,7 @@ import { useEffect, useState } from "react" import { useRouter } from "next/navigation" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" +import { Textarea } from "@/components/ui/textarea" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" import { Badge } from "@/components/ui/badge" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" @@ -17,12 +18,13 @@ import { DialogTrigger, } from "@/components/ui/dialog" import { Label } from "@/components/ui/label" -import { Plus, Search, Loader2, RefreshCw, Building2, CheckCircle, XCircle, Eye, Trash2, Pencil } from "lucide-react" +import { Plus, Search, Loader2, RefreshCw, Building2, CheckCircle, XCircle, Eye, EyeOff, Trash2, Pencil } from "lucide-react" import { Switch } from "@/components/ui/switch" import { adminCompaniesApi, type AdminCompany } from "@/lib/api" import { getCurrentUser, isAdminUser } from "@/lib/auth" import { toast } from "sonner" import { Skeleton } from "@/components/ui/skeleton" +import { useTranslation } from "@/lib/i18n" const companyDateFormatter = new Intl.DateTimeFormat("en-US", { dateStyle: "medium", @@ -56,7 +58,28 @@ const formatDescription = (description: string | undefined) => { return
{description}
} +// Format CNPJ: 00.000.000/0000-00 +const formatCNPJ = (value: string) => { + return value + .replace(/\D/g, "") + .replace(/^(\d{2})(\d)/, "$1.$2") + .replace(/^(\d{2})\.(\d{3})(\d)/, "$1.$2.$3") + .replace(/\.(\d{3})(\d)/, ".$1/$2") + .replace(/(\d{4})(\d)/, "$1-$2") + .substring(0, 18) +} + +// Format Phone: (00) 00000-0000 +const formatPhone = (value: string) => { + return value + .replace(/\D/g, "") + .replace(/^(\d{2})(\d)/, "($1) $2") + .replace(/(\d{5})(\d)/, "$1-$2") + .substring(0, 15) +} + export default function AdminCompaniesPage() { + const { t } = useTranslation() const router = useRouter() const [companies, setCompanies] = useStateManage all registered companies
+{t('admin.companies.subtitle')}