From ad558bc656c7d72bba781e33bd8ee824bba790bc Mon Sep 17 00:00:00 2001 From: NANDO9322 Date: Thu, 8 Jan 2026 17:14:41 -0300 Subject: [PATCH] =?UTF-8?q?feat:=20melhorias=20na=20gest=C3=A3o=20de=20emp?= =?UTF-8?q?resas=20e=20corre=C3=A7=C3=B5es=20de=20persist=C3=AAncia?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Backend: Correção na persistência do email da empresa (CreateCompanyUseCase) e suporte a exclusão em cascata (Cascade Delete) para evitar erro 500. - Backend: Adicionado suporte completo para Phone, Website, Address, Description e Slug na criação. - Backend: Correção crítica no JobService para ocultar nome de candidatos na listagem de vagas. - Frontend: Adição da coluna 'Email' na listagem de empresas e padronização dos ícones de ação. - Frontend: Inclusão de novas chaves de tradução (i18n) e melhorias no modal de criação. --- .../internal/api/handlers/core_handlers.go | 4 + .../internal/core/domain/entity/company.go | 22 +- backend/internal/core/dto/company.go | 1 + .../core/usecases/auth/register_candidate.go | 1 + .../core/usecases/tenant/create_company.go | 46 ++- backend/internal/dto/auth.go | 1 + .../postgres/company_repository.go | 23 +- backend/internal/services/admin_service.go | 67 +++- backend/internal/services/job_service.go | 5 +- frontend/src/app/dashboard/companies/page.tsx | 307 ++++++++++++++---- frontend/src/app/dashboard/jobs/new/page.tsx | 22 +- frontend/src/app/dashboard/jobs/page.tsx | 114 ++----- frontend/src/app/dashboard/users/page.tsx | 46 +-- frontend/src/i18n/en.json | 125 +++++++ frontend/src/i18n/pt-BR.json | 125 +++++++ 15 files changed, 700 insertions(+), 209 deletions(-) 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] = useState([]) const [loading, setLoading] = useState(true) @@ -69,10 +92,19 @@ export default function AdminCompaniesPage() { const [creating, setCreating] = useState(false) const [updating, setUpdating] = useState(false) const [isEditDialogOpen, setIsEditDialogOpen] = useState(false) + const [showPassword, setShowPassword] = useState(false) + const [showConfirmPassword, setShowConfirmPassword] = useState(false) const [formData, setFormData] = useState({ name: "", slug: "", email: "", + password: "", + confirmPassword: "", + document: "", + phone: "", + website: "", + address: "", + description: "", }) const [editFormData, setEditFormData] = useState({ name: "", @@ -121,14 +153,35 @@ export default function AdminCompaniesPage() { const handleCreate = async () => { try { setCreating(true) - await adminCompaniesApi.create(formData) - toast.success("Company created successfully!") + // Strip non-digits for payload + const payload = { + ...formData, + document: formData.document.replace(/\D/g, ''), + phone: formData.phone.replace(/\D/g, ''), + } + await adminCompaniesApi.create(payload) + toast.success(t('admin.companies.success.created')) setIsDialogOpen(false) - setFormData({ name: "", slug: "", email: "" }) + setFormData({ + name: "", + slug: "", + email: "", + password: "", + confirmPassword: "", + document: "", + phone: "", + website: "", + address: "", + description: "", + }) loadCompanies(1) // Reload first page - } catch (error) { + } catch (error: any) { console.error("Error creating company:", error) - toast.error("Failed to create company") + if (error.message?.includes("already exists")) { + toast.error(t('admin.companies.error.emailExists', { defaultValue: "User with this email already exists" })) + } else { + toast.error("Failed to create company") + } } finally { setCreating(false) } @@ -147,7 +200,7 @@ export default function AdminCompaniesPage() { try { await adminCompaniesApi.updateStatus(company.id, { [field]: newValue }) - toast.success(`Company ${field} updated`) + toast.success(t('admin.companies.success.statusUpdated', { field })) } catch (error) { toast.error(`Failed to update ${field}`) setCompanies(originalCompanies) @@ -164,11 +217,11 @@ export default function AdminCompaniesPage() { } const handleDelete = async (company: AdminCompany) => { - if (!window.confirm(`Are you sure you want to delete ${company.name}? This action cannot be undone.`)) return + if (!window.confirm(t('admin.companies.deleteConfirm', { name: company.name }))) return try { await adminCompaniesApi.delete(company.id) - toast.success("Company deleted successfully") + toast.success(t('admin.companies.success.deleted')) setIsViewDialogOpen(false) loadCompanies() } catch (error) { @@ -218,7 +271,7 @@ export default function AdminCompaniesPage() { description: editFormData.description, }) - toast.success("Company updated successfully") + toast.success(t('admin.companies.success.updated')) setIsEditDialogOpen(false) loadCompanies() } catch (error) { @@ -240,29 +293,29 @@ export default function AdminCompaniesPage() { {/* Header */}
-

Company management

-

Manage all registered companies

+

{t('admin.companies.title')}

+

{t('admin.companies.subtitle')}

- Create new company - Fill in the company details + {t('admin.companies.create.title')} + {t('admin.companies.create.subtitle')}
- +
- + setFormData({ ...formData, slug: e.target.value })} - placeholder="empresa-xyz" + placeholder={t('admin.companies.create.slugPlaceholder')} />
- + + setFormData({ ...formData, document: formatCNPJ(e.target.value) })} + placeholder="CNPJ / Document" + /> +
+
+ setFormData({ ...formData, email: e.target.value })} - placeholder="hello@company.com" + placeholder={t('admin.companies.create.emailPlaceholder')} + /> +
+
+ +
+ setFormData({ ...formData, password: e.target.value })} + placeholder="******" + /> + +
+
+
+ +
+ setFormData({ ...formData, confirmPassword: e.target.value })} + placeholder="******" + /> + +
+ {formData.password !== formData.confirmPassword && formData.confirmPassword && ( +

{t('admin.companies.fields.passwordsDoNotMatch')}

+ )} +
+
+ + setFormData({ ...formData, phone: formatPhone(e.target.value) })} + placeholder="+55 11 99999-9999" + /> +
+
+ + setFormData({ ...formData, website: e.target.value })} + placeholder="https://..." + /> +
+
+ + setFormData({ ...formData, address: e.target.value })} + placeholder="Address..." + /> +
+
+ +